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:
董宏凯andClaude Opus 4.8 committed 2026-06-07 22:30:23 +08:00
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}
@@ -49,6 +49,10 @@ class ChainResponse(BaseModel):
# Optional layered orchestration: list of stages, each a list of agent_ids
# run in parallel (stages run serially). None/absent = linear chain.
stages: list[list[str]] | None = None
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -73,6 +77,8 @@ class ChainUpdateRequest(BaseModel):
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] | None = None
stages: list[list[str]] | None = None
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None
# ── helpers ──────────────────────────────────────────────────────────────
@@ -149,10 +155,18 @@ def _validate_stages(seats: list[ChainSeatSchema], stages: list[list[str]] | Non
@router.get("", response_model=ChainListResponse)
async def list_chains(request: Request) -> ChainListResponse:
async def list_chains(request: Request, scope: str = "mine") -> ChainListResponse:
"""列出业务链条。
``scope`` 查询参数(筛选):
- ``mine``(默认):仅当前用户自己的链条;
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨)。
其余值按 ``mine`` 处理。
"""
normalized_scope = scope if scope in ("mine", "public") else "mine"
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id)
rows = await store.list_chains(user_id, scope=normalized_scope)
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
@@ -0,0 +1,27 @@
"""Merge chain_stages and admin_leaderboard_daily_stats migration heads.
Two independent migrations diverged from ``20260607_02``: ``20260607_03``
(roundtable_chains.stages) and ``20260608_01`` (admin_leaderboard_daily_stats +
analytics indexes). This no-op merge revision joins both lineages so
``upgrade head`` succeeds.
Revision ID: 20260608_02
Revises: 20260607_03, 20260608_01
Create Date: 2026-06-08
"""
from __future__ import annotations
revision = "20260608_02"
down_revision = ("20260607_03", "20260608_01")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,58 @@
"""Add is_public column to roundtable_chains.
「发布」business chains: ``is_public=True`` makes a chain visible to **all
accounts** under the "公共" filter (read + select-into-roundtable only; edit /
delete stay owner-only). Defaults to ``0`` (private). ``create_all`` adds the
column for brand-new DBs; this revision patches DBs that already have the table.
Chained after the merge head ``20260608_02`` (chain_stages + leaderboard) so the
revision graph stays single-headed — otherwise ``alembic upgrade head`` aborts
with "multiple heads" and the auto-migrate on startup silently falls back to the
create_all schema (which lacks this column → 500 on list).
Revision ID: 20260608_03
Revises: 20260608_02
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260608_03"
down_revision = "20260608_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "is_public"):
op.add_column(
"roundtable_chains",
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="0"),
)
op.create_index(
"ix_roundtable_chains_is_public", "roundtable_chains", ["is_public"]
)
def downgrade() -> None:
if _has_column("roundtable_chains", "is_public"):
with op.batch_alter_table("roundtable_chains") as batch:
batch.drop_index("ix_roundtable_chains_is_public")
op.drop_column("roundtable_chains", "is_public")
@@ -14,7 +14,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -39,6 +39,11 @@ class RoundtableChainRow(Base):
# (each seat its own stage). Drives Step 2 dag-mode orchestration; see
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
stages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
is_public: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0", index=True
)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
@@ -44,7 +44,7 @@ class RoundtableChainRepository:
self._sf = session_factory
@staticmethod
def _to_dict(row: RoundtableChainRow) -> dict[str, Any]:
def _to_dict(row: RoundtableChainRow, current_user_id: str | None = None) -> dict[str, Any]:
return {
"id": row.id,
"title": row.title,
@@ -52,27 +52,46 @@ class RoundtableChainRepository:
"seats": _loads(row.seats) or [],
# None when absent → frontend reads it as "linear chain" (no layering).
"stages": _loads(row.stages),
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}
async def list_chains(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
async def list_chains(
self, user_id: str, *, scope: str = "mine", limit: int = 200
) -> list[dict[str, Any]]:
"""列出链条。
``scope``:
- ``"mine"``(默认):仅当前用户自己的链条;
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的。
其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。
"""
if scope == "public":
cond = RoundtableChainRow.is_public.is_(True)
else:
cond = RoundtableChainRow.user_id == user_id
stmt = (
select(RoundtableChainRow)
.where(RoundtableChainRow.user_id == user_id)
.where(cond)
.order_by(RoundtableChainRow.updated_at.desc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._to_dict(row) for row in result.scalars()]
return [self._to_dict(row, user_id) for row in result.scalars()]
async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None:
"""取一条链条:owner 可见,**已发布的公共链条任何人可见**(供选入研讨)。"""
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
if row is None or row.user_id != user_id:
if row is None:
return None
return self._to_dict(row)
if row.user_id != user_id and not bool(getattr(row, "is_public", False)):
return None
return self._to_dict(row, user_id)
async def create_chain(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
@@ -83,6 +102,7 @@ class RoundtableChainRepository:
description=(data.get("description") or None),
seats=_dumps(data.get("seats") or []),
stages=_dumps(data.get("stages")),
is_public=bool(data.get("is_public") or False),
created_at=now,
updated_at=now,
)
@@ -90,7 +110,7 @@ class RoundtableChainRepository:
session.add(row)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
return self._to_dict(row, user_id)
async def update_chain(
self,
@@ -101,6 +121,7 @@ class RoundtableChainRepository:
description=_UNSET,
seats=_UNSET,
stages=_UNSET,
is_public=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id)
@@ -114,10 +135,12 @@ class RoundtableChainRepository:
row.seats = _dumps(seats)
if stages is not _UNSET:
row.stages = _dumps(stages)
if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._to_dict(row)
return self._to_dict(row, user_id)
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
async with self._sf() as session: