Merge remote-tracking branch 'upstream/main'

This commit is contained in:
✨await
2026-09-17 10:53:42 +08:00
21 changed files with 450 additions and 169 deletions

View File

@@ -42,6 +42,7 @@ type CanvasProjectContextMenuProps = {
onSpreadSelection: () => void;
onCopySelection: () => void;
onDeleteSelection: () => void;
onSendToAgent: () => void;
};
export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props }: CanvasProjectContextMenuProps) {
@@ -114,6 +115,7 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
onSpreadSelection={props.onSpreadSelection}
onCopySelection={props.onCopySelection}
onDeleteSelection={props.onDeleteSelection}
onSendToAgent={props.onSendToAgent}
/>
);
}

View File

@@ -20,13 +20,14 @@ type CanvasProjectSelectionToolbarProps = {
onCreateReferenceGroup: () => void;
onBatchConnect: () => void;
onMergeVideos: () => void;
onSendSelectionToAgent: () => void;
};
export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos }: CanvasProjectSelectionToolbarProps) {
export function CanvasProjectSelectionToolbar({ anchorRef, containerRef, count, selectedVideoCount, mergingVideos, onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos, onSendSelectionToAgent }: CanvasProjectSelectionToolbarProps) {
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
const handlers = {
onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos,
onAlign, onArrange, onCreateStoryboard, onCreateReferenceGroup, onBatchConnect, onMergeVideos, onSendSelectionToAgent,
} as Partial<ToolbarHandlers> as ToolbarHandlers;
const ctx: ToolContext = {

View File

@@ -77,6 +77,7 @@ import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
import {
applyCanvasConnectionPromptSync,
buildCanvasAgentMentionReferences,
canvasResourceMentionToken,
buildCanvasNodeMentionReferenceMap,
buildCanvasResourceReferences,
getContextResourceNodes,
@@ -292,6 +293,7 @@ function InfiniteCanvasPage() {
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [agentPrefillPrompt, setAgentPrefillPrompt] = useState("");
const [isMiniMapOpen, setIsMiniMapOpen] = useState(false);
const [canvasAppearance, setCanvasAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
const [backgroundMode, setBackgroundMode] = useState<CanvasBackgroundMode>(DEFAULT_CANVAS_BACKGROUND_MODE);
@@ -333,6 +335,15 @@ function InfiniteCanvasPage() {
const [cinematicAgentEntry, setCinematicAgentEntry] = useState(false);
const { assistantOpen, closeAgent, openAgent } = useCanvasAssistantVisibility();
const agentMentionReferences = useMemo(() => buildCanvasAgentMentionReferences(nodes), [nodes]);
const sendSelectionToAgent = useCallback((nodeId?: string) => {
const ids = nodeId ? [nodeId] : Array.from(selectedNodeIdsRef.current);
const references = ids.map((id) => agentMentionReferences.find((reference) => reference.nodeId === id)).filter((reference): reference is CanvasResourceReference => Boolean(reference));
if (!references.length) return;
setAgentPrefillPrompt(`${references.map(canvasResourceMentionToken).join(" ")} `);
openAgent();
setContextMenu(null);
}, [agentMentionReferences, openAgent]);
const { tasks: activeTasks } = useCanvasActiveTasks(projectId, projectLoaded);
const { focusMode, enterFocusMode, exitFocusMode, toggleFocusMode } = useFocusMode();
const [focusDockRevealed, setFocusDockRevealed] = useState(false);
@@ -2582,7 +2593,7 @@ function InfiniteCanvasPage() {
) : null}
</div>
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} nodeCount={nodes.length} references={agentMentionReferences} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} nodeCount={nodes.length} references={agentMentionReferences} prefillPrompt={agentPrefillPrompt} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; }
focusCanvasNode(nodeId);
}} />
@@ -2697,6 +2708,7 @@ function InfiniteCanvasPage() {
onCreateReferenceGroup={createReferenceGroup}
onBatchConnect={() => beginBatchConnectionMode(Array.from(selectedNodeIds))}
onMergeVideos={() => void mergeSelectedVideos()}
onSendSelectionToAgent={() => sendSelectionToAgent()}
/>
) : null}
@@ -2843,6 +2855,7 @@ function InfiniteCanvasPage() {
onSpreadSelection={spreadSelectedNodes}
onCopySelection={copySelectedNodes}
onDeleteSelection={() => deleteNodes(selectedNodeIds)}
onSendToAgent={() => sendSelectionToAgent(contextMenu?.type === "node" && selectedNodeIds.size <= 1 ? contextMenu.nodeId : undefined)}
/>
<CanvasUploadModal open={uploadModalOpen} onClose={closeUploadModal} onUpload={handleUploadFiles} />

View File

@@ -1,21 +1,13 @@
/* Creation-specific geometry. Shared controls still use the workspace tokens. */
.creation-mode-tabs { position: relative; isolation: isolate; display: grid; width: min(456px, 100%); margin: 0 auto 20px; padding: 4px; border-radius: 18px; background: var(--user-surface-muted); }
.creation-mode-indicator { position: absolute; z-index: -1; top: 4px; left: 4px; bottom: 4px; border-radius: 14px; background: var(--user-surface); box-shadow: 0 2px 8px color-mix(in srgb, var(--user-ink) 5%, transparent); }
.creation-mode-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 0; height: 44px; padding: 0 12px; border: 0; border-radius: 14px; background: transparent; color: var(--user-ink-muted); font-size: 14px; font-weight: 600; cursor: pointer; transition: color var(--user-motion), transform var(--user-motion); }
.creation-mode-button svg { width: 18px; height: 18px; stroke-width: 1.8; }
.creation-mode-button[data-mode="video"] svg { color: var(--user-accent); }
.creation-mode-button[data-mode="image"] svg { color: var(--product-accent-green); }
.creation-mode-button[data-mode="text"] svg { color: var(--product-accent-orange); }
.creation-mode-button[data-mode="agent"] svg { color: var(--product-accent-blue); }
.creation-mode-indicator { position: absolute; z-index: -1; inset: 0; border-radius: 14px; background: var(--user-surface); box-shadow: 0 2px 8px color-mix(in srgb, var(--user-ink) 5%, transparent); }
.creation-mode-button { position: relative; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 0; height: 44px; padding: 0 12px; border: 0; border-radius: 14px; background: transparent; color: var(--user-ink-muted); font-size: 14px; font-weight: 600; cursor: pointer; transition: color var(--user-motion), transform var(--user-motion); }
.creation-mode-button svg { width: 18px; height: 18px; stroke-width: 1.8; color: currentColor; }
.creation-mode-button[aria-pressed="true"], .creation-mode-button:hover { color: var(--user-ink); }
.creation-mode-button:active { transform: scale(.97); }
.creation-mode-button:focus-visible { outline: 2px solid var(--user-accent); outline-offset: 2px; }
.creation-mode-spark { width: 5px; height: 5px; border-radius: 50%; background: var(--user-accent); box-shadow: 0 0 0 3px var(--user-accent-soft); }
.creation-mode-tabs[data-active-mode="agent"] .creation-mode-indicator { background: linear-gradient(115deg, var(--user-accent-soft), color-mix(in srgb, var(--product-accent-blue) 12%, var(--user-surface))); }
.creation-mode-tabs[data-active-mode="video"] .creation-mode-indicator { background: linear-gradient(115deg, var(--user-surface), var(--user-accent-soft)); }
.creation-mode-tabs[data-active-mode="image"] .creation-mode-indicator { background: color-mix(in srgb, var(--product-accent-green) 9%, var(--user-surface)); }
.creation-mode-tabs[data-active-mode="text"] .creation-mode-indicator { background: color-mix(in srgb, var(--product-accent-orange) 9%, var(--user-surface)); }
.creation-agent-entry { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 16px 24px; padding: 28px; margin-bottom: 20px; border-radius: var(--user-radius-card); background: radial-gradient(ellipse at top right, var(--user-accent-soft), transparent 60%), var(--user-surface); animation: product-card-enter var(--product-enter) var(--product-ease) backwards; }
.creation-mode-spark { width: 4px; height: 4px; border-radius: 50%; background: currentColor; opacity: .42; box-shadow: none; }
.creation-agent-entry { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 16px 24px; padding: 28px; margin-bottom: 20px; border-radius: var(--user-radius-card); background: var(--user-surface); animation: product-card-enter var(--product-enter) var(--product-ease) backwards; }
.creation-agent-orb { grid-row: span 2; }
.creation-agent-eyebrow { font-size: 10px; font-weight: 650; letter-spacing: .14em; color: var(--user-accent); }
.creation-agent-copy h2 { margin: 4px 0 8px; font-size: 21px; font-weight: 650; color: var(--user-ink); }
@@ -69,34 +61,27 @@
min-width: 36px !important;
height: 36px !important;
border-radius: 999px !important;
background: color-mix(in srgb, var(--product-accent-blue) 12%, var(--user-surface-muted)) !important;
color: var(--product-accent-blue) !important;
background: var(--user-surface-muted) !important;
color: var(--user-ink-muted) !important;
transition: background var(--user-motion), color var(--user-motion), transform var(--user-motion), box-shadow var(--user-motion) !important;
}
.app-user-workspace .creation-voice-trigger.ant-btn:hover,
.app-user-workspace .creation-voice-trigger.ant-btn:focus-visible {
background: color-mix(in srgb, var(--product-accent-blue) 20%, var(--user-surface)) !important;
color: var(--product-accent-blue) !important;
background: var(--user-surface-hover) !important;
color: var(--user-ink) !important;
transform: translateY(-1px);
box-shadow: 0 8px 18px color-mix(in srgb, var(--product-accent-blue) 18%, transparent);
box-shadow: 0 8px 18px color-mix(in srgb, var(--user-ink) 8%, transparent);
}
.app-user-workspace .creation-chat-control > svg:first-child,
.app-user-workspace .creation-model-picker .canvas-model-picker-trigger-icon { transition: transform var(--user-motion), color var(--user-motion); }
.app-user-workspace .creation-model-picker .canvas-model-picker-trigger-icon { transition: transform var(--user-motion), color var(--user-motion); color: currentColor; }
.app-user-workspace .creation-chat-control:hover > svg:first-child,
.app-user-workspace .creation-model-picker:hover .canvas-model-picker-trigger-icon { transform: scale(1.08); }
.app-user-workspace .creation-chat-control:has(.lucide-wand-sparkles) > svg:first-child { color: var(--product-accent-violet); }
.app-user-workspace .creation-chat-control:has(.lucide-clock-3) > svg:first-child { color: var(--product-accent-orange); }
.app-user-workspace .creation-chat-control:has(.lucide-sliders-horizontal) > svg:first-child { color: var(--product-accent-green); }
.app-user-workspace .creation-chat-control:has(.lucide-waves) > svg:first-child { color: var(--product-accent-cyan); }
.app-user-workspace .creation-chat-control:has(.lucide-brain) > svg:first-child { color: var(--product-accent-blue); }
.app-user-workspace .creation-chat-control.is-clear > svg:first-child { color: #c45b5b; }
.app-user-workspace .creation-chat-control.is-clear:hover { background: color-mix(in srgb, #c45b5b 12%, var(--user-surface)) !important; color: #c45b5b !important; }
.app-user-workspace .creation-model-picker .canvas-model-picker-trigger-icon { color: var(--product-accent-orange); }
.app-user-workspace .creation-model-picker:hover .canvas-model-picker-trigger-icon { transform: scale(1.06); }
.app-user-workspace .creation-chat-control.is-clear:hover { background: color-mix(in srgb, var(--status-error, #c45b5b) 12%, var(--user-surface)) !important; color: var(--status-error, #c45b5b) !important; }
.app-user-workspace .creation-launchpad .creation-reference-add-button:hover:not(:disabled) {
border-style: solid;
border-color: var(--user-accent);
background: color-mix(in srgb, var(--user-accent) 12%, var(--user-surface));
color: var(--user-accent);
border-color: var(--user-border-strong);
background: var(--user-surface-hover);
color: var(--user-ink);
transform: translateY(-1px);
}
.app-user-workspace .creation-launchpad .creation-reference-add-button:active:not(:disabled) { transform: scale(.97); }

View File

@@ -5,7 +5,7 @@ import { App, Button, Dropdown, Popover } from "antd";
import { AppDrawer } from "@/components/ui/product/app-drawer";
import { AppModal } from "@/components/ui/product/app-modal";
import { Tooltip } from "@/components/ui/base/tooltip";
import { Reorder, motion, useReducedMotion } from "motion/react";
import { Reorder, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { ArrowDown, ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
@@ -20,7 +20,10 @@ import { CachedResourceImage } from "@/components/cached-resource-image";
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { HoverBorderGradient } from "@/components/ui/aceternity/hover-border-gradient";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { ModelPicker } from "@/components/model-picker";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
import { formatShotOrdinal } from "@/lib/shot-label";
@@ -482,7 +485,13 @@ export function CreationComposer(props: ComposerProps) {
}
return undefined;
};
const composer = <section className={`creation-chat-composer is-${props.variant}`}>
const composer = <HoverBorderGradient as="div" duration={2.2} containerClassName="creation-composer-shell" className="creation-composer-shell-inner">
<SpotlightSurface
className={`creation-chat-composer is-${props.variant}`}
contentClassName="contents"
spotlightColor="color-mix(in srgb, var(--user-ink) 12%, transparent)"
spotlightRadius={280}
>
{props.variant === "thread" ? <div className="creation-composer-mode-row"><ModePicker mode={props.mode} onModeChange={props.onModeChange} /></div> : null}
<div className="creation-chat-writing-surface">
<div className="creation-chat-editor">
@@ -600,7 +609,8 @@ export function CreationComposer(props: ComposerProps) {
</Button>
</footer>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</section>;
</SpotlightSurface>
</HoverBorderGradient>;
if (!promptOptimizerOpen) return composer;
@@ -630,18 +640,26 @@ export function CreationModeTabs({ mode, onModeChange, agentActive = false, onAg
{ mode: "image", icon: <ImageIcon />, label: "图片" },
{ mode: "text", icon: <MessageSquareText />, label: "文本" },
];
const activeIndex = agentActive ? 3 : items.findIndex((item) => item.mode === mode);
const vertical = orientation === "vertical";
return <div className="creation-mode-tabs" role="group" aria-label="创作模式" data-active-mode={agentActive ? "agent" : mode} data-orientation={orientation} style={{ gridTemplateColumns: vertical ? "minmax(0, 1fr)" : `repeat(${onAgentSelect ? 4 : 3}, minmax(0, 1fr))` }}>
<motion.span aria-hidden className="creation-mode-indicator" initial={false} animate={vertical ? { y: `${activeIndex * 100}%` } : { x: `${activeIndex * 100}%` }} transition={{ duration: reducedMotion ? 0 : .3, ease: [.16, 1, .3, 1] }} style={vertical ? { width: "calc(100% - 8px)", height: `calc((100% - 8px) / ${onAgentSelect ? 4 : 3})`, bottom: "auto" } : { width: onAgentSelect ? "calc((100% - 8px) / 4)" : "calc((100% - 8px) / 3)" }} />
const indicator = (pressed: boolean) => pressed ? (
<motion.span
layoutId={`creation-mode-indicator-${orientation}`}
className="creation-mode-indicator"
aria-hidden
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock}
/>
) : null;
return <LayoutGroup id={`creation-mode-tabs-${orientation}`}>
<div className="creation-mode-tabs" role="group" aria-label="创作模式" data-active-mode={agentActive ? "agent" : mode} data-orientation={orientation} style={{ gridTemplateColumns: orientation === "vertical" ? "minmax(0, 1fr)" : `repeat(${onAgentSelect ? 4 : 3}, minmax(0, 1fr))` }}>
{items.map((item) => (
<button key={item.mode} type="button" className="creation-mode-button" data-mode={item.mode} aria-pressed={!agentActive && item.mode === mode} aria-label={`${item.label}生成`} onClick={() => onModeChange(item.mode)}>
{indicator(!agentActive && item.mode === mode)}
{item.icon}
<span>{item.label}</span>
</button>
))}
{onAgentSelect ? <button type="button" className="creation-mode-button" data-mode="agent" aria-pressed={agentActive} onClick={onAgentSelect}><Brain /><span>Agent</span><i className="creation-mode-spark" aria-hidden /></button> : null}
</div>;
{onAgentSelect ? <button type="button" className="creation-mode-button" data-mode="agent" aria-pressed={agentActive} onClick={onAgentSelect}>{indicator(agentActive)}<Brain /><span>Agent</span><i className="creation-mode-spark" aria-hidden /></button> : null}
</div>
</LayoutGroup>;
}
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
@@ -735,22 +753,43 @@ const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapper
];
export function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) {
return <div className="creation-empty-suggest" aria-label="快捷创作入口">
const reducedMotion = useReducedMotion();
const [hovered, setHovered] = useState<string | null>(null);
return <LayoutGroup id="creation-empty-suggest">
<div className="creation-empty-suggest" aria-label="快捷创作入口">
{creationEmptySuggestions.map((item) => {
const Icon = item.icon;
const start = () => {
if (item.openLibrary) onOpenLibrary();
else onStartPrompt(item.mode, item.prompt);
};
return <button key={item.title} type="button" className="suggest-card" onClick={start}>
<span className={`library-icon-tile suggest-icon is-${item.mode}`}><Icon size={18} strokeWidth={2} /></span>
return <motion.button
key={item.title}
type="button"
className="suggest-card"
onClick={start}
onHoverStart={() => setHovered(item.title)}
onHoverEnd={() => setHovered(null)}
whileHover={reducedMotion ? undefined : { y: -2 }}
transition={aceternityMotion.spring.surface}
>
{hovered === item.title ? (
<motion.span
layoutId="creation-suggest-hover"
className="suggest-card-hover"
aria-hidden
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.surface}
/>
) : null}
<span className="library-icon-tile suggest-icon"><Icon size={18} strokeWidth={2} /></span>
<span className="suggest-copy">
<strong>{item.title}</strong>
<span>{item.hint}</span>
</span>
</button>;
</motion.button>;
})}
</div>;
</div>
</LayoutGroup>;
}

View File

@@ -0,0 +1,90 @@
export type WelcomeContributor = {
name: string;
avatar: string;
signature: string;
email?: string;
wechat?: string;
isFounder?: boolean;
};
function plainText(value: string) {
return value
.replace(/<br\s*\/?>/gi, " · ")
.replace(/<[^>]+>/g, "")
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
function wechatFrom(details: string) {
return details.match(/微信\s*[::]\s*([^·]+)/)?.[1]?.trim();
}
function parsePipeContributor(line: string, resolveAvatar: (path: string) => string | undefined): WelcomeContributor[] {
const columns = line
.slice(1, line.lastIndexOf("|"))
.split("|")
.map((column) => column.trim());
const image = columns[0]?.match(/src="([^"]+)"[^>]*alt="([^"]*)"/i);
const nameColumn = columns[1] ?? "";
const name = plainText(nameColumn.split(/<br\s*\/?>/i)[0] ?? image?.[2] ?? "");
const avatarPath = image?.[1];
if (!avatarPath || !name) return [];
const avatar = resolveAvatar(avatarPath);
if (!avatar) return [];
const nameDetails = plainText(nameColumn);
const email = plainText(columns[2] ?? "");
return [
{
name,
avatar,
signature: plainText(columns[3] ?? ""),
email: email || undefined,
wechat: wechatFrom(nameDetails),
isFounder: nameDetails.includes("项目发起者"),
},
];
}
function parseHtmlContributor(cell: string, resolveAvatar: (path: string) => string | undefined): WelcomeContributor[] {
const avatarPath = cell.match(/<img\s[^>]*\bsrc="([^"]+)"/i)?.[1];
const alt = cell.match(/<img\s[^>]*\balt="([^"]*)"/i)?.[1] ?? "";
const strong = cell.match(/<strong>([\s\S]*?)<\/strong>/i)?.[1] ?? "";
const name = plainText(strong.split(/<br\s*\/?>/i)[0] ?? "") || plainText(alt);
if (!avatarPath || !name) return [];
const avatar = resolveAvatar(avatarPath);
if (!avatar) return [];
const nameDetails = plainText(strong) || name;
const email = decodeURIComponent(cell.match(/mailto:([^"'>\s]+)/i)?.[1] ?? "") || plainText(cell.match(/<a\b[^>]*>([\s\S]*?)<\/a>/i)?.[1] ?? "");
return [
{
name,
avatar,
signature: plainText(cell.match(/<em>([\s\S]*?)<\/em>/i)?.[1] ?? ""),
email: email || undefined,
wechat: wechatFrom(nameDetails),
isFounder: nameDetails.includes("项目发起者"),
},
];
}
export function parseContributors(markdown: string, resolveAvatar: (path: string) => string | undefined): WelcomeContributor[] {
const section = markdown.match(/## 贡献者与团队([\s\S]*?)(?=\n##\s|$)/)?.[1];
if (!section) return [];
const htmlCells = section.match(/<td\b[\s\S]*?<\/td>/gi) ?? [];
if (htmlCells.length) {
return htmlCells.flatMap((cell) => parseHtmlContributor(cell, resolveAvatar));
}
return section
.split("\n")
.filter((line) => /^\|\s*<img\s/i.test(line))
.flatMap((line) => parsePipeContributor(line, resolveAvatar));
}

View File

@@ -1,65 +1,13 @@
import readmeMarkdown from "../../../../README.md?raw";
import { parseContributors } from "./contributors-parse";
const contributorAvatarModules = import.meta.glob("../../../../assets/user-*", {
eager: true,
import: "default",
query: "?url",
}) as Record<string, string>;
export type WelcomeContributor = {
name: string;
avatar: string;
signature: string;
email?: string;
wechat?: string;
isFounder?: boolean;
};
export type { WelcomeContributor } from "./contributors-parse";
function plainText(value: string) {
return value
.replace(/<br\s*\/?>/gi, " · ")
.replace(/<[^>]+>/g, "")
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
function parseContributors(markdown: string): WelcomeContributor[] {
const section = markdown.match(/## 贡献者与团队([\s\S]*?)(?=\n##\s)/)?.[1];
if (!section) return [];
return section
.split("\n")
.filter((line) => /^\|\s*<img\s/i.test(line))
.flatMap((line) => {
const columns = line
.slice(1, line.lastIndexOf("|"))
.split("|")
.map((column) => column.trim());
const image = columns[0]?.match(/src="([^"]+)"[^>]*alt="([^"]*)"/i);
const nameColumn = columns[1] ?? "";
const name = plainText(nameColumn.split(/<br\s*\/?>/i)[0] ?? image?.[2] ?? "");
const avatarPath = image?.[1];
if (!avatarPath || !name) return [];
const avatar = contributorAvatarModules[`../../../../${avatarPath}`];
if (!avatar) return [];
const nameDetails = plainText(nameColumn);
const email = plainText(columns[2] ?? "");
const wechat = nameDetails.match(/微信\s*[::]\s*([^·]+)/)?.[1]?.trim();
return [
{
name,
avatar,
signature: plainText(columns[3] ?? ""),
email: email || undefined,
wechat,
isFounder: nameDetails.includes("项目发起者"),
},
];
});
}
export const welcomeContributors = parseContributors(readmeMarkdown);
export const welcomeContributors = parseContributors(readmeMarkdown, (avatarPath) => contributorAvatarModules[`../../../../${avatarPath}`]);