feat(roundtable): 业务链条「发布为公共」+ 我的/公共筛选
后端:roundtable_chains 加 is_public 列;list_chains 支持 scope=mine|public(public=所有已发布,任何账号可查看/选入研讨);get_chain 放开公共可见;响应加 is_public/is_mine;update 支持 is_public(发布/取消,owner 限定,编辑/删除仍仅 owner)。迁移 20260608_03 接在合并 head 20260608_02 之后,保证单一 alembic head(多 head 会让启动自动迁移失败、静默降级 create_all → 列缺失 500)。 前端:listChains(scope) + setChainPublic;BusinessChain 加 isPublic/isMine;列表弹窗与研讨选择器加「我的/公共」筛选 tab;列表卡片发布/取消发布 + 公共徽标(别人的只读);配置页头部加发布按钮(已保存且本人)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
05d14bb364
commit
7affaaf7c0
10 files changed
+373
-44
No files matched your search
@@ -33,8 +33,13 @@ export interface ChainUpdatePayload {
|
||||
seats?: ChainSeat[];
|
||||
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
|
||||
stages?: string[][] | null;
|
||||
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/** 列表筛选范围:`mine` = 我的;`public` = 所有已发布的公共链条。 */
|
||||
export type ChainScope = "mine" | "public";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
@@ -55,6 +60,8 @@ interface ServerChain {
|
||||
description: string | null;
|
||||
seats: ChainSeat[];
|
||||
stages?: string[][] | null;
|
||||
is_public?: boolean;
|
||||
is_mine?: boolean;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@@ -66,6 +73,9 @@ function toChain(s: ServerChain): BusinessChain {
|
||||
description: s.description ?? undefined,
|
||||
seats: Array.isArray(s.seats) ? s.seats : [],
|
||||
stages: Array.isArray(s.stages) ? s.stages : null,
|
||||
isPublic: s.is_public ?? false,
|
||||
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
||||
isMine: s.is_mine ?? true,
|
||||
createdAt: s.created_at ?? undefined,
|
||||
updatedAt: s.updated_at ?? undefined,
|
||||
};
|
||||
@@ -88,18 +98,29 @@ function fromUpdate(p: ChainUpdatePayload) {
|
||||
if (p.seats !== undefined) out.seats = p.seats;
|
||||
// Forward stages even when explicitly null (clears layering server-side).
|
||||
if (p.stages !== undefined) out.stages = p.stages;
|
||||
if (p.isPublic !== undefined) out.is_public = p.isPublic;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** List the current user's chains, newest first. */
|
||||
export async function listChains(): Promise<BusinessChain[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
/**
|
||||
* List chains, newest first. `scope`:
|
||||
* - `"mine"`(默认):当前用户自己的链条;
|
||||
* - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。
|
||||
*/
|
||||
export async function listChains(scope: ChainScope = "mine"): Promise<BusinessChain[]> {
|
||||
const url = scope === "public" ? `${BASE()}?scope=public` : BASE();
|
||||
const res = await apiFetch(url, { method: "GET" });
|
||||
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
|
||||
return (data.chains ?? []).map(toChain);
|
||||
}
|
||||
|
||||
/** 发布 / 取消发布一条链条(仅 owner 生效)。返回更新后的链条,404 返回 null。 */
|
||||
export async function setChainPublic(id: string, isPublic: boolean): Promise<BusinessChain | null> {
|
||||
return updateChain(id, { isPublic });
|
||||
}
|
||||
|
||||
/** Fetch one chain. Returns null on 404. */
|
||||
export async function getChain(id: string): Promise<BusinessChain | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
* 卡片席位名直接用落库快照(`seat.name`),无需实时校验,点「设置」进编辑页后
|
||||
* 会看到实时数据。
|
||||
*/
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
GlobeIcon,
|
||||
LayersIcon,
|
||||
Loader2Icon,
|
||||
SettingsIcon,
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { listChains } from "../api/chains";
|
||||
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
|
||||
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
|
||||
@@ -47,16 +48,32 @@ export function BusinessChainListDialog({
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 筛选:我的 / 公共(已发布)。
|
||||
const [scope, setScope] = useState<ChainScope>("mine");
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
|
||||
// 每次打开都重新拉一次,保证配置页改动后回来看到的是最新列表。
|
||||
const refresh = useCallback(async (s: ChainScope) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await listChains(s);
|
||||
setChains(list);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 每次打开 / 切换筛选都重新拉,保证配置页改动后回来看到的是最新列表。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await listChains();
|
||||
const list = await listChains(scope);
|
||||
if (!cancelled) setChains(list);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -67,7 +84,22 @@ export function BusinessChainListDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, scope]);
|
||||
|
||||
const handleTogglePublic = useCallback(
|
||||
async (chain: BusinessChain) => {
|
||||
setPublishingId(chain.id);
|
||||
try {
|
||||
await setChainPublic(chain.id, !chain.isPublic);
|
||||
await refresh(scope);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPublishingId(null);
|
||||
}
|
||||
},
|
||||
[refresh, scope],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -78,10 +110,29 @@ export function BusinessChainListDialog({
|
||||
业务链条
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条。
|
||||
可复用的有序智能体编排。点击卡片「设置」可进入配置页编辑该链条;「发布」后所有账号都能在「公共」里查看 / 选用。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 筛选:我的 / 公共 */}
|
||||
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
|
||||
{(["mine", "public"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
scope === s
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s === "mine" ? "我的" : "公共"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-10 text-sm text-muted-foreground">
|
||||
@@ -95,9 +146,13 @@ export function BusinessChainListDialog({
|
||||
) : chains.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<LayersIcon className="size-10 text-primary/40" />
|
||||
<div className="text-sm font-semibold">还没有业务链条</div>
|
||||
<div className="text-sm font-semibold">
|
||||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。
|
||||
{scope === "public"
|
||||
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
|
||||
: "业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -106,7 +161,9 @@ export function BusinessChainListDialog({
|
||||
<ChainRow
|
||||
key={chain.id}
|
||||
chain={chain}
|
||||
publishing={publishingId === chain.id}
|
||||
onSettings={() => onConfigure(chain.id)}
|
||||
onTogglePublic={() => handleTogglePublic(chain)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -128,28 +185,61 @@ export function BusinessChainListDialog({
|
||||
|
||||
function ChainRow({
|
||||
chain,
|
||||
publishing,
|
||||
onSettings,
|
||||
onTogglePublic,
|
||||
}: {
|
||||
chain: BusinessChain;
|
||||
publishing: boolean;
|
||||
onSettings: () => void;
|
||||
onTogglePublic: () => void;
|
||||
}) {
|
||||
const isMine = chain.isMine !== false;
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
|
||||
{/* 标题行 + 设置按钮 */}
|
||||
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="truncate text-base font-semibold">{chain.title || "未命名链条"}</div>
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
|
||||
{chain.seats.length} 席
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSettings}
|
||||
className="ml-auto h-8 shrink-0 gap-1.5 px-3 text-sm"
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
设置
|
||||
</Button>
|
||||
{chain.isPublic && (
|
||||
<span className="shrink-0 inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<GlobeIcon className="size-3" />
|
||||
公共
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
{/* 发布 / 取消发布:仅本人的链条可操作。 */}
|
||||
{isMine && (
|
||||
<Button
|
||||
variant={chain.isPublic ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
onClick={onTogglePublic}
|
||||
disabled={publishing}
|
||||
className="h-8 gap-1.5 px-3 text-sm"
|
||||
>
|
||||
{publishing ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<GlobeIcon className="size-4" />
|
||||
)}
|
||||
{chain.isPublic ? "取消发布" : "发布"}
|
||||
</Button>
|
||||
)}
|
||||
{/* 设置(编辑):仅本人的链条;公共里别人的链条只读浏览。 */}
|
||||
{isMine && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSettings}
|
||||
className="h-8 gap-1.5 px-3 text-sm"
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
设置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chain.description && (
|
||||
|
||||
@@ -29,7 +29,7 @@ import { cn } from "@/lib/utils";
|
||||
import { listAgents } from "@/core/agents";
|
||||
|
||||
import { filterRecommendCandidates } from "../api/recommend";
|
||||
import { listChains } from "../api/chains";
|
||||
import { listChains, type ChainScope } from "../api/chains";
|
||||
import {
|
||||
CHAIN_MIN_SEATS,
|
||||
chainTagClasses,
|
||||
@@ -55,16 +55,21 @@ export function BusinessChainPicker({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// 筛选:我的 / 公共(已发布)。
|
||||
const [scope, setScope] = useState<ChainScope>("mine");
|
||||
// null = 候选池未知(agent 列表加载失败)→ 不把任何席位判成「已删除」。
|
||||
const [existingIds, setExistingIds] = useState<Set<string> | null>(null);
|
||||
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelectedId(null); // 切换筛选后清空选中(旧选中可能不在新列表里)。
|
||||
void (async () => {
|
||||
try {
|
||||
const [chainList, agents] = await Promise.all([
|
||||
listChains(),
|
||||
listChains(scope),
|
||||
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
|
||||
]);
|
||||
if (cancelled) return;
|
||||
@@ -86,7 +91,7 @@ export function BusinessChainPicker({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [scope]);
|
||||
|
||||
const isExisting = useMemo(
|
||||
() => (agentId: string) => existingIds === null || existingIds.has(agentId),
|
||||
@@ -136,6 +141,24 @@ export function BusinessChainPicker({
|
||||
return (
|
||||
<>
|
||||
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{/* 筛选:我的 / 公共 */}
|
||||
<div className="mb-2 flex shrink-0 items-center gap-1 self-start rounded-lg border border-border bg-muted/40 p-0.5">
|
||||
{(["mine", "public"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1 text-xs font-medium transition",
|
||||
scope === s
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{s === "mine" ? "我的" : "公共"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1 -mr-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-8 text-sm text-muted-foreground">
|
||||
@@ -149,15 +172,20 @@ export function BusinessChainPicker({
|
||||
) : chains.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center">
|
||||
<LayersIcon className="size-10 text-primary/40" />
|
||||
<div className="text-sm font-semibold">还没有业务链条</div>
|
||||
<div className="text-sm font-semibold">
|
||||
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,
|
||||
之后即可在这里选择使用。
|
||||
{scope === "public"
|
||||
? "公共链条是别人(或你自己)发布出来、所有账号可选用的链条。可在「我的」里发布一条。"
|
||||
: "业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,之后即可在这里选择使用。"}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onGoConfig} className="gap-1.5">
|
||||
<SettingsIcon className="size-4" />
|
||||
去配置业务链条
|
||||
</Button>
|
||||
{scope === "mine" && (
|
||||
<Button variant="outline" size="sm" onClick={onGoConfig} className="gap-1.5">
|
||||
<SettingsIcon className="size-4" />
|
||||
去配置业务链条
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5 py-0.5">
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface BusinessChain {
|
||||
* (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。
|
||||
*/
|
||||
stages?: string[][] | null;
|
||||
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
|
||||
isPublic?: boolean;
|
||||
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
||||
isMine?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
GitBranch,
|
||||
Globe,
|
||||
GripVertical,
|
||||
Layers,
|
||||
Loader2,
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
createChain,
|
||||
deleteChain,
|
||||
listChains,
|
||||
setChainPublic,
|
||||
updateChain,
|
||||
} from "@/roundtable-planning/api/chains";
|
||||
import {
|
||||
@@ -130,6 +132,10 @@ export default function BusinessChainEditorPage() {
|
||||
|
||||
// ─── 当前编辑中的链条 ─────────────────────────────────────────────────
|
||||
const [currentChainId, setCurrentChainId] = useState<string | null>(null);
|
||||
// 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。
|
||||
const [currentChainPublic, setCurrentChainPublic] = useState(false);
|
||||
const [currentChainMine, setCurrentChainMine] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
|
||||
@@ -218,6 +224,8 @@ export default function BusinessChainEditorPage() {
|
||||
// ─── 链条选择 / 新建 ──────────────────────────────────────────────────
|
||||
const loadChain = useCallback((chain: BusinessChain) => {
|
||||
setCurrentChainId(chain.id);
|
||||
setCurrentChainPublic(chain.isPublic ?? false);
|
||||
setCurrentChainMine(chain.isMine !== false);
|
||||
setTitle(chain.title);
|
||||
setDescription(chain.description ?? "");
|
||||
setStageGroups(chainToStageGroups(chain));
|
||||
@@ -225,6 +233,8 @@ export default function BusinessChainEditorPage() {
|
||||
|
||||
const startNewChain = useCallback(() => {
|
||||
setCurrentChainId(null);
|
||||
setCurrentChainPublic(false);
|
||||
setCurrentChainMine(true);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setStageGroups([]);
|
||||
@@ -462,6 +472,32 @@ export default function BusinessChainEditorPage() {
|
||||
}
|
||||
}, [currentChainId, title, startNewChain, refreshChains]);
|
||||
|
||||
// 发布 / 取消发布:把当前链条设为公共(所有账号可见 / 选用)或收回。
|
||||
const handleTogglePublish = useCallback(async () => {
|
||||
if (!currentChainId) {
|
||||
toast.warning("请先保存链条再发布");
|
||||
return;
|
||||
}
|
||||
const next = !currentChainPublic;
|
||||
setPublishing(true);
|
||||
try {
|
||||
const updated = await setChainPublic(currentChainId, next);
|
||||
if (!updated) {
|
||||
toast.error("链条已不存在,可能被删除");
|
||||
startNewChain();
|
||||
await refreshChains();
|
||||
return;
|
||||
}
|
||||
setCurrentChainPublic(updated.isPublic ?? next);
|
||||
toast.success(next ? "已发布为公共,所有账号都能查看 / 选用" : "已取消发布");
|
||||
await refreshChains();
|
||||
} catch (err) {
|
||||
toast.error(`${next ? "发布" : "取消发布"}失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}, [currentChainId, currentChainPublic, startNewChain, refreshChains]);
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────
|
||||
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
|
||||
|
||||
@@ -587,6 +623,29 @@ export default function BusinessChainEditorPage() {
|
||||
|
||||
{/* 右侧操作 */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* 发布 / 取消发布:仅已保存且本人所有的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */}
|
||||
{currentChainId && currentChainMine && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePublish}
|
||||
disabled={publishing}
|
||||
title={currentChainPublic ? "已发布为公共,点击收回为私有" : "发布为公共,所有账号都能查看 / 选用"}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
currentChainPublic
|
||||
? "border-emerald-400/50 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/15 dark:text-emerald-400"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
{publishing ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Globe className="size-4" />
|
||||
)}
|
||||
{currentChainPublic ? "已发布" : "发布"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
|
||||
Reference in new issue
Block a user