fix all bug

This commit is contained in:
王性驊 2026-08-19 02:36:53 +00:00
parent b12474a232
commit 3b113feb3a
15 changed files with 289 additions and 249 deletions

View File

@ -10,7 +10,8 @@
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
"react-router-dom": "^7.18.1",
"react-useanimations": "^2.10.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@ -2617,6 +2618,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lottie-web": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz",
"integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==",
"license": "MIT"
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@ -2916,6 +2923,19 @@
"react-dom": ">=18"
}
},
"node_modules/react-useanimations": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/react-useanimations/-/react-useanimations-2.10.0.tgz",
"integrity": "sha512-MzGNv8vkvb6qEvMBCj+O6nUloUHSJRubMAH3uE7J4M+pjt5ud5xDaXBrQgv5GbvBg29XzviKWHTfvvkofDEu+Q==",
"license": "MIT",
"dependencies": {
"lottie-web": "^5.5.7"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",

View File

@ -15,7 +15,8 @@
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
"react-router-dom": "^7.18.1",
"react-useanimations": "^2.10.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",

View File

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../../auth/AuthContext";
import { AccountAvatar } from "../ui/AccountAvatar";
import { AppIcon } from "../ui/AppIcons";
import { useI18n } from "../../i18n/I18nContext";
import { memberRoleSummary } from "../../lib/memberRole";
import { can, Permission } from "../../lib/permissions";
@ -49,7 +50,7 @@ export function AccountMenu() {
<AccountAvatar account={avatarAccount} className="hb-account__avatar" />
<span className="hb-account__name">{name}</span>
<span className="hb-account__chev" aria-hidden>
<AppIcon name="chevron" size={18} />
</span>
</button>

View File

@ -6,7 +6,7 @@ import type { AppNotification } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import { pathForNotification } from "../../lib/nav";
import { formatLocalDateTime, formatTimeAgo } from "../../lib/time";
import { Button } from "../ui";
import { AppIcon, Button } from "../ui";
const PREVIEW = 5;
@ -117,21 +117,7 @@ export function BellMenu() {
title={t("topbar.notifications")}
>
<span className="hb-bell__icon" aria-hidden>
{/* 簡化鈴鐺:幾何,不用 emoji */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path
d="M6 9.5a6 6 0 1 1 12 0c0 3.2 1.2 4.6 1.8 5.2H4.2C4.8 14.1 6 12.7 6 9.5Z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
<path
d="M10 18.2a2 2 0 0 0 4 0"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
<AppIcon name="bell" size={18} />
</span>
{unread > 0 ? (
<span className="hb-bell__badge">{unread > 9 ? "9+" : unread}</span>

View File

@ -103,7 +103,11 @@ export function MobileDock() {
<AppIcon name={item.key} size={20} />
</span>
<span className="hb-dock-more__row-label">{t(item.labelKey)}</span>
{active ? <span className="hb-dock-more__check" aria-hidden></span> : null}
{active ? (
<span className="hb-dock-more__check" aria-hidden>
<AppIcon name="check" size={16} />
</span>
) : null}
</button>
</li>
);

View File

@ -18,6 +18,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { Link, useLocation } from "react-router-dom";
import { AppIcon } from "../ui/AppIcons";
import { useI18n } from "../../i18n/I18nContext";
import {
PAGE_HELP_RELATED,
@ -150,7 +151,7 @@ export function PageHelpTrigger({ className = "" }: { className?: string }) {
aria-haspopup="dialog"
title={`${t("help.open")} (? )`}
>
<span aria-hidden>?</span>
<AppIcon name="help" size={18} />
</button>
);
}

View File

@ -4,7 +4,7 @@ import type { Brand, BrandProduct, CostPreview, ExploreResult, WatchTermSuggesti
import { useI18n } from "../../i18n/I18nContext";
import { useFormatApiError } from "../../lib/apiErrors";
import { checkThreadsTerm, isThreadsSearchable, normalizeSearchTerm } from "../../lib/threadsTerm";
import { Badge, Button, Input } from "../ui";
import { AppIcon, Badge, Button, Input } from "../ui";
import { CostPreviewDialog } from "./CostPreviewDialog";
const MAX_TERMS = 6;
@ -188,7 +188,7 @@ export function ExplorePanel({ onExplored }: { onExplored: () => void }) {
>
<Badge tone="brand">{term}</Badge>
<span className="hb-radar-explore__chip-x" aria-hidden>
×
<AppIcon name="close" size={14} reverse />
</span>
</button>
))

View File

@ -1,5 +1,5 @@
import { useRef, useState } from "react";
import { Button } from "../ui";
import { AppIcon, Button } from "../ui";
import { useI18n } from "../../i18n/I18nContext";
import {
compressDataUrlForUpload,
@ -149,7 +149,7 @@ export function ImageAttach({
disabled={disabled}
onClick={() => remove(img.id)}
>
×
<AppIcon name="close" size={14} reverse />
</button>
</div>
))}

View File

@ -0,0 +1,33 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AppIcon } from "./AppIcons";
describe("AppIcon", () => {
it("injects an svg from useanimations data", () => {
const { container } = render(
<button type="button">
<AppIcon name="bell" size={24} />
</button>,
);
const host = container.querySelector(".hb-app-icon");
expect(host).toBeTruthy();
expect(host?.querySelector("svg")).toBeTruthy();
});
it("starts animating when the parent is hovered", async () => {
const { container } = render(
<button type="button">
<AppIcon name="bell" size={24} />
</button>,
);
const host = container.querySelector(".hb-app-icon");
const before = host?.innerHTML ?? "";
host?.closest("button")?.dispatchEvent(new Event("pointerenter", { bubbles: true }));
await new Promise((resolve) => {
window.setTimeout(resolve, 80);
});
const after = host?.innerHTML ?? "";
expect(after.length).toBeGreaterThan(0);
expect(after !== before || after.includes("transform")).toBe(true);
});
});

View File

@ -1,210 +1,197 @@
import type { ReactNode, SVGProps } from "react";
import { useEffect, useRef } from "react";
import lottieMod from "lottie-web";
import archive from "react-useanimations/lib/archive";
import arrowDownCircle from "react-useanimations/lib/arrowDownCircle";
import bookmark from "react-useanimations/lib/bookmark";
import calendar from "react-useanimations/lib/calendar";
import checkmark from "react-useanimations/lib/checkmark";
import copy from "react-useanimations/lib/copy";
import edit from "react-useanimations/lib/edit";
import explore from "react-useanimations/lib/explore";
import folder from "react-useanimations/lib/folder";
import help from "react-useanimations/lib/help";
import lock from "react-useanimations/lib/lock";
import mail from "react-useanimations/lib/mail";
import menu2 from "react-useanimations/lib/menu2";
import notification2 from "react-useanimations/lib/notification2";
import plusToX from "react-useanimations/lib/plusToX";
import searchToX from "react-useanimations/lib/searchToX";
import share from "react-useanimations/lib/share";
import skipForward from "react-useanimations/lib/skipForward";
import star from "react-useanimations/lib/star";
import thumbUp from "react-useanimations/lib/thumbUp";
import userPlus from "react-useanimations/lib/userPlus";
import visibility from "react-useanimations/lib/visibility";
import type { NavKey } from "../../lib/nav";
export type AppIconName = NavKey | "more" | "spark" | "send";
type LottieItem = {
play: () => void;
destroy: () => void;
setDirection: (direction: number) => void;
goToAndStop: (value: number, isFrame?: boolean) => void;
totalFrames: number;
};
type LottiePlayer = {
loadAnimation: (opts: {
container: Element;
renderer: "svg";
loop: boolean;
autoplay: boolean;
animationData: unknown;
}) => LottieItem;
};
type Glyph = { animationData: unknown; animationKey: string };
/** CJS 套件在 Vite 下 default 可能包一層 `{ default: x }`。 */
function unwrapDefault<T>(mod: T | { default: T }): T {
if (typeof mod === "function") return mod;
if (mod && typeof mod === "object" && "default" in mod) {
return (mod as { default: T }).default;
}
return mod as T;
}
function asGlyph(mod: unknown): Glyph {
const inner = unwrapDefault(mod) as Glyph;
return {
animationKey: inner.animationKey,
animationData: unwrapDefault(inner.animationData),
};
}
const lottie = unwrapDefault(lottieMod) as LottiePlayer;
export type AppIconName =
| NavKey
| "more"
| "spark"
| "send"
| "bell"
| "visibility"
| "chevron"
| "help"
| "close"
| "check";
type Props = {
name: AppIconName;
size?: number;
className?: string;
} & Omit<SVGProps<SVGSVGElement>, "name">;
/** 密碼眼睛等:點一下才播,不要 hover 就劃掉 */
play?: "hover" | "click";
/** 一開始停在最後一格(例如 close 顯示 X */
reverse?: boolean;
};
const glyphs: Record<AppIconName, Glyph> = {
today: asGlyph(calendar),
crew: asGlyph(userPlus),
studio: asGlyph(edit),
scout: asGlyph(searchToX),
radar: asGlyph(explore),
crm: asGlyph(archive),
outbox: asGlyph(mail),
jobs: asGlyph(folder),
brands: asGlyph(bookmark),
policy: asGlyph(lock),
playbooks: asGlyph(copy),
insights: asGlyph(thumbUp),
benchmark: asGlyph(star),
utm: asGlyph(share),
more: asGlyph(menu2),
spark: asGlyph(star),
send: asGlyph(skipForward),
bell: asGlyph(notification2),
visibility: asGlyph(visibility),
chevron: asGlyph(arrowDownCircle),
help: asGlyph(help),
close: asGlyph(plusToX),
check: asGlyph(checkmark),
};
/**
* 稿 icon
* currentColor teal / coral light / dark
* react-useanimations Lottie
* 18px hover cleanup instance
*/
export function AppIcon({ name, size = 22, className = "", ...rest }: Props) {
export function AppIcon({
name,
size = 22,
className = "",
play = "hover",
reverse = false,
}: Props) {
const hostRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const host = hostRef.current;
const glyph = glyphs[name];
if (!host || !glyph?.animationData || typeof lottie?.loadAnimation !== "function") {
return;
}
host.replaceChildren();
const anim = lottie.loadAnimation({
container: host,
renderer: "svg",
loop: false,
autoplay: false,
animationData: glyph.animationData,
});
if (reverse && anim.totalFrames > 0) {
anim.goToAndStop(anim.totalFrames - 1, true);
}
const target = host.closest("a, button, [data-icon-host]") ?? host;
let direction = reverse ? -1 : 1;
const playForward = () => {
if (reverse) {
anim.goToAndStop(0, true);
}
anim.setDirection(1);
anim.play();
};
const playBack = () => {
if (reverse) {
anim.goToAndStop(Math.max(0, anim.totalFrames - 1), true);
return;
}
anim.setDirection(-1);
anim.play();
};
const playToggle = () => {
anim.setDirection(direction);
anim.play();
direction *= -1;
};
if (play === "click") {
target.addEventListener("click", playToggle);
} else {
target.addEventListener("pointerenter", playForward);
target.addEventListener("pointerleave", playBack);
}
return () => {
if (play === "click") {
target.removeEventListener("click", playToggle);
} else {
target.removeEventListener("pointerenter", playForward);
target.removeEventListener("pointerleave", playBack);
}
anim.destroy();
};
}, [name, play, reverse]);
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
<span
ref={hostRef}
className={`hb-app-icon ${className}`.trim()}
style={{ width: size, height: size }}
aria-hidden
{...rest}
>
{glyphs[name]}
</svg>
/>
);
}
const stroke = {
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
/** 小星芒:點綴色走主題 magic / accent */
function Spark({
x = 18,
y = 5,
s = 1,
tone = "magic",
}: {
x?: number;
y?: number;
s?: number;
tone?: "magic" | "accent";
}) {
// magic 不用 --hb-magic那是次色奶油黃當線條放在淺底上幾乎看不見。
const color = tone === "accent" ? "var(--hb-accent-warm)" : "var(--hb-brand-deep)";
return (
<g opacity={0.9} transform={`translate(${x} ${y}) scale(${s})`}>
<path
d="M0-2.2V2.2M-2.2 0H2.2"
stroke={color}
strokeWidth={1.35}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
);
}
const glyphs: Record<AppIconName, ReactNode> = {
// 今日:圓角日曆 + 星
today: (
<>
<rect x="3.5" y="5" width="17" height="15" rx="3.5" {...stroke} />
<path d="M8 3.75v3.1M16 3.75v3.1M3.5 10.25h17" {...stroke} />
<circle cx="9.2" cy="14.6" r="1.05" fill="currentColor" opacity={0.9} />
<Spark x={16.2} y={14.4} s={0.72} />
</>
),
// 帳號:雙圓 + 柔弧(島民)
crew: (
<>
<circle cx="9" cy="8.6" r="3" {...stroke} />
<path d="M4 18.2c.55-2.9 2.7-4.35 5-4.35 2.3 0 4.45 1.45 5 4.35" {...stroke} />
<circle cx="16.6" cy="9.4" r="2.35" {...stroke} opacity={0.85} />
<path d="M14.2 18.2c.35-1.85 1.55-2.85 3.15-2.85 1.35 0 2.45.7 3.05 1.9" {...stroke} opacity={0.85} />
<Spark x={18.8} y={5.2} s={0.55} tone="accent" />
</>
),
// 創作:羽毛筆 + 星
studio: (
<>
<path
d="M5.2 19.2 14.6 5.4a1.7 1.7 0 0 1 2.45-.15l1.9 1.9a1.7 1.7 0 0 1-.15 2.45L9.3 19.2H5.2z"
{...stroke}
/>
<path d="M12.7 7.9 16.1 11.3" {...stroke} />
<path d="M5.2 19.2 8.1 18.3" {...stroke} opacity={0.7} />
<Spark x={18.5} y={5.5} s={0.7} tone="accent" />
</>
),
// 海巡:水晶球 / 羅盤弧 + 十字光
scout: (
<>
<circle cx="11.2" cy="11.2" r="6.4" {...stroke} />
<circle cx="11.2" cy="11.2" r="2.15" {...stroke} opacity={0.55} />
<path d="M11.2 6.4v9.6M6.4 11.2h9.6" {...stroke} opacity={0.55} />
<path d="M15.8 15.8 20.2 20.2" {...stroke} />
<Spark x={18.2} y={6.2} s={0.62} />
</>
),
// 雷達:同心圓掃描
radar: (
<>
<circle cx="12" cy="12" r="7.2" {...stroke} />
<circle cx="12" cy="12" r="4.1" {...stroke} opacity={0.65} />
<circle cx="12" cy="12" r="1.2" fill="currentColor" opacity={0.85} />
<path d="M12 12 17.2 7.4" {...stroke} />
<Spark x={17.6} y={6.8} s={0.55} tone="accent" />
</>
),
// 名單:看板卡片
crm: (
<>
<rect x="3.8" y="5" width="5.2" height="14" rx="1.4" {...stroke} />
<rect x="9.4" y="5" width="5.2" height="14" rx="1.4" {...stroke} opacity={0.85} />
<rect x="15" y="5" width="5.2" height="14" rx="1.4" {...stroke} opacity={0.7} />
<Spark x={18.4} y={7.2} s={0.5} />
</>
),
// 發送:紙飛機 + 軌跡星
outbox: (
<>
<path d="M4.2 11.1 19.6 4.6 12.2 19.8l-1.7-6.3z" {...stroke} />
<path d="M10.5 13.5 19.6 4.6" {...stroke} opacity={0.65} />
<Spark x={7.2} y={7.5} s={0.5} tone="accent" />
</>
),
// 任務:魔法卷軸/手提袋簡化
jobs: (
<>
<rect x="4.2" y="7.2" width="15.6" height="12.2" rx="2.6" {...stroke} />
<path d="M9 7.2V5.9a2.1 2.1 0 0 1 2.1-2.1h1.8A2.1 2.1 0 0 1 15 5.9v1.3" {...stroke} />
<path d="M4.2 12.1h15.6" {...stroke} opacity={0.55} />
<Spark x={17.5} y={15.8} s={0.55} />
</>
),
// 品牌:旗幟堡壘簡化 + 星
brands: (
<>
<path d="M6.2 20V9.1L12 5.2 17.8 9.1V20" {...stroke} />
<path d="M10.2 20v-4.6h3.6V20" {...stroke} />
<path d="M12 9.6v2.4" {...stroke} opacity={0.55} />
<Spark x={17.8} y={6.4} s={0.58} tone="accent" />
</>
),
// 商機政策:盾牌 + 核取,代表全域回覆與篩選規則
policy: (
<>
<path d="M12 3.8 19 6.5v5.2c0 4.3-2.7 7.2-7 8.7-4.3-1.5-7-4.4-7-8.7V6.5z" {...stroke} />
<path d="m8.7 12 2.1 2.1 4.5-4.6" {...stroke} />
</>
),
playbooks: (
<>
<path d="M6 5.5h9.5a2 2 0 0 1 2 2V19l-3.2-1.8L11 19l-3.3-1.8L4.5 19V7.5a2 2 0 0 1 2-2z" {...stroke} />
<path d="M8.2 9.5h7.2M8.2 12.2h7.2M8.2 14.9h4.5" {...stroke} opacity={0.7} />
</>
),
insights: (
<>
<path d="M5 18.5V10.2M10 18.5V6.5M15 18.5v-5.2M20 18.5V8.8" {...stroke} />
<path d="M4.5 18.8h16" {...stroke} opacity={0.5} />
<Spark x={18.5} y={6.2} s={0.55} />
</>
),
benchmark: (
<>
<circle cx="12" cy="12" r="7" {...stroke} />
<circle cx="12" cy="12" r="3.2" {...stroke} opacity={0.7} />
<path d="M12 5v2.2M12 16.8V19M5 12h2.2M16.8 12H19" {...stroke} opacity={0.55} />
</>
),
utm: (
<>
<path d="M8 12.5h9.5" {...stroke} />
<path d="M14.2 8.8 18.5 12.5 14.2 16.2" {...stroke} />
<circle cx="6.2" cy="12.5" r="2" {...stroke} />
</>
),
more: (
<>
<circle cx="6.5" cy="12" r="1.45" fill="currentColor" />
<circle cx="12" cy="12" r="1.45" fill="currentColor" />
<circle cx="17.5" cy="12" r="1.45" fill="currentColor" />
</>
),
spark: (
<>
<Spark x={12} y={12} s={1.35} />
<circle cx="12" cy="12" r="1.1" fill="currentColor" opacity={0.35} />
</>
),
// 送出:置中紙飛機(小圓鈕專用,無星芒避免破圖感)
send: (
<>
<path
d="M4 11.5 19.5 4.2a.7.7 0 0 1 .95.9L13.6 20.3a.7.7 0 0 1-1.28.08L9.7 14.1 4 11.5z"
{...stroke}
/>
<path d="M9.7 14.1 19.7 5" {...stroke} opacity={0.55} />
</>
),
};

View File

@ -1,4 +1,5 @@
import { useState, type InputHTMLAttributes } from "react";
import { AppIcon } from "./AppIcons";
type Props = InputHTMLAttributes<HTMLInputElement> & {
label?: string;
@ -54,7 +55,7 @@ export function Input({
aria-pressed={visible}
onClick={() => setVisible((v) => !v)}
>
<PasswordEyeIcon hidden={visible} />
<AppIcon name="visibility" size={20} play="click" />
</button>
) : null}
</span>
@ -68,26 +69,3 @@ export function Input({
</label>
);
}
function PasswordEyeIcon({ hidden }: { hidden: boolean }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M3.5 12s3.2-6 8.5-6 8.5 6 8.5 6-3.2 6-8.5 6-8.5-6-8.5-6Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="2.4" stroke="currentColor" strokeWidth="1.5" />
{hidden ? (
<path
d="M4 20 20 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
) : null}
</svg>
);
}

View File

@ -1052,7 +1052,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
title={t("inspire.unpinTitle")}
aria-label={t("inspire.unpinTitle")}
>
×
<AppIcon name="close" size={14} reverse />
</button>
</div>
))}
@ -1317,7 +1317,7 @@ function ElementRow({
aria-label={t("inspire.deleteAria")}
onClick={onRemove}
>
×
<AppIcon name="close" size={16} reverse />
</button>
</div>
);

View File

@ -2301,8 +2301,6 @@ svg {
.hb-inspire-send-btn .hb-app-icon {
display: block;
flex-shrink: 0;
/* 紙飛機視覺重心略偏左,微調置中 */
transform: translate(1px, 0.5px);
}
.hb-inspire-send-btn__dots {

View File

@ -1211,10 +1211,28 @@ a.hb-topbar__chip:hover {
}
.hb-dock__icon svg,
.hb-app-icon {
.hb-app-icon,
.hb-app-icon svg {
display: block;
}
.hb-app-icon {
flex-shrink: 0;
line-height: 0;
overflow: hidden;
}
.hb-app-icon svg {
width: 100%;
height: 100%;
}
.hb-app-icon path[stroke],
.hb-app-icon circle[stroke],
.hb-app-icon rect[stroke] {
stroke: currentColor !important;
}
.hb-dock-more__ico {
display: grid;
place-items: center;

View File

@ -2,6 +2,19 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
/** lottie-web 載入時會摸 canvasjsdom 的 getContext 會丟 Not implemented。 */
HTMLCanvasElement.prototype.getContext = function getContext(this: HTMLCanvasElement) {
return {
fillStyle: "",
fillRect() {},
drawImage() {},
getImageData() {
return { data: new Uint8ClampedArray(4) };
},
canvas: this,
};
} as unknown as typeof HTMLCanvasElement.prototype.getContext;
afterEach(() => {
cleanup();
});