feat: 新增技能置顶功能
- 后端:新增 PUT /skills/custom/{name}/featured 接口,在 usage 中实现 set_featured 与 featured 字段(与 pinned 正交,仅影响排序)
- 前端:新增 setSkillFeatured API、置顶/取消置顶操作及中英文文案
- ScheduledTasksPage:优化助手选择下拉框的宽度与文本换行
- config.yaml:整理 log_system 配置块
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b117bb1dc2
commit
eaa061dd06
11 files changed
+156
-9
No files matched your search
@@ -4,6 +4,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
ArrowDownUpIcon,
|
ArrowDownUpIcon,
|
||||||
|
BookmarkIcon,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
@@ -67,6 +68,7 @@ import {
|
|||||||
useDuplicateSkill,
|
useDuplicateSkill,
|
||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
useReconcileCustomSkills,
|
useReconcileCustomSkills,
|
||||||
|
useSetSkillFeatured,
|
||||||
useSetSkillPinned,
|
useSetSkillPinned,
|
||||||
useSetSkillPublished,
|
useSetSkillPublished,
|
||||||
useSkills,
|
useSkills,
|
||||||
@@ -201,8 +203,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
filteredSkills
|
filteredSkills
|
||||||
.map((skill, index) => ({ skill, index }))
|
.map((skill, index) => ({ skill, index }))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const pinnedDelta = Number(!!b.skill.pinned) - Number(!!a.skill.pinned);
|
// 置顶(featured)决定排序;钉选(pinned)只管防归档,不影响顺序。
|
||||||
return pinnedDelta || a.index - b.index;
|
const featuredDelta =
|
||||||
|
Number(!!b.skill.featured) - Number(!!a.skill.featured);
|
||||||
|
return featuredDelta || a.index - b.index;
|
||||||
})
|
})
|
||||||
.map(({ skill }) => skill),
|
.map(({ skill }) => skill),
|
||||||
[filteredSkills],
|
[filteredSkills],
|
||||||
@@ -431,6 +435,7 @@ export function SkillRow({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const setPublished = useSetSkillPublished();
|
const setPublished = useSetSkillPublished();
|
||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
|
const setFeatured = useSetSkillFeatured();
|
||||||
const deleteSkill = useDeleteCustomSkill();
|
const deleteSkill = useDeleteCustomSkill();
|
||||||
const duplicateSkillMutation = useDuplicateSkill();
|
const duplicateSkillMutation = useDuplicateSkill();
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
@@ -510,6 +515,20 @@ export function SkillRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleFeatured() {
|
||||||
|
const next = !skill.featured;
|
||||||
|
try {
|
||||||
|
await setFeatured.mutateAsync({ skillName: skill.name, featured: next });
|
||||||
|
toast.success(
|
||||||
|
next
|
||||||
|
? t.settings.skills.featureSuccess
|
||||||
|
: t.settings.skills.unfeatureSuccess,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||||
@@ -577,6 +596,15 @@ export function SkillRow({
|
|||||||
{t.settings.skills.badgeSourceUpload}
|
{t.settings.skills.badgeSourceUpload}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{skill.featured && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
|
||||||
|
title={t.settings.skills.badgeFeaturedTitle}
|
||||||
|
>
|
||||||
|
{t.settings.skills.badgeFeatured}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{skill.pinned && (
|
{skill.pinned && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -617,6 +645,27 @@ export function SkillRow({
|
|||||||
<TagsIcon className="size-3.5" />
|
<TagsIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
skill.featured && "text-amber-600 hover:text-amber-700",
|
||||||
|
)}
|
||||||
|
disabled={setFeatured.isPending}
|
||||||
|
onClick={() => void handleToggleFeatured()}
|
||||||
|
title={
|
||||||
|
skill.featured
|
||||||
|
? t.settings.skills.unfeatureAction
|
||||||
|
: t.settings.skills.featureAction
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PinIcon
|
||||||
|
className={cn("size-3.5", skill.featured && "fill-current")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -633,7 +682,9 @@ export function SkillRow({
|
|||||||
: t.settings.skills.pinAction
|
: t.settings.skills.pinAction
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PinIcon className={cn("size-3.5", skill.pinned && "fill-current")} />
|
<BookmarkIcon
|
||||||
|
className={cn("size-3.5", skill.pinned && "fill-current")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canTogglePublish && (
|
{canTogglePublish && (
|
||||||
|
|||||||
@@ -476,6 +476,8 @@ export const enUS: Translations = {
|
|||||||
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
|
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
|
||||||
badgePinned: "Pinned",
|
badgePinned: "Pinned",
|
||||||
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
|
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
|
||||||
|
badgeFeatured: "Top",
|
||||||
|
badgeFeaturedTitle: "Pinned to the top of the list",
|
||||||
badgeSourceAgent: "Agent-created",
|
badgeSourceAgent: "Agent-created",
|
||||||
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
||||||
badgeSourceUpload: "Uploaded",
|
badgeSourceUpload: "Uploaded",
|
||||||
@@ -494,6 +496,10 @@ export const enUS: Translations = {
|
|||||||
unpinAction: "Unpin",
|
unpinAction: "Unpin",
|
||||||
pinSuccess: "Pinned",
|
pinSuccess: "Pinned",
|
||||||
unpinSuccess: "Unpinned",
|
unpinSuccess: "Unpinned",
|
||||||
|
featureAction: "Pin to top",
|
||||||
|
unfeatureAction: "Unpin from top",
|
||||||
|
featureSuccess: "Pinned to top",
|
||||||
|
unfeatureSuccess: "Unpinned from top",
|
||||||
deleteAction: "Delete skill",
|
deleteAction: "Delete skill",
|
||||||
deleteConfirm:
|
deleteConfirm:
|
||||||
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
||||||
|
|||||||
@@ -409,6 +409,8 @@ export interface Translations {
|
|||||||
badgeArchivedTitle: string;
|
badgeArchivedTitle: string;
|
||||||
badgePinned: string;
|
badgePinned: string;
|
||||||
badgePinnedTitle: string;
|
badgePinnedTitle: string;
|
||||||
|
badgeFeatured: string;
|
||||||
|
badgeFeaturedTitle: string;
|
||||||
badgeSourceAgent: string;
|
badgeSourceAgent: string;
|
||||||
badgeSourceAgentTitle: string;
|
badgeSourceAgentTitle: string;
|
||||||
badgeSourceUpload: string;
|
badgeSourceUpload: string;
|
||||||
@@ -427,6 +429,10 @@ export interface Translations {
|
|||||||
unpinAction: string;
|
unpinAction: string;
|
||||||
pinSuccess: string;
|
pinSuccess: string;
|
||||||
unpinSuccess: string;
|
unpinSuccess: string;
|
||||||
|
featureAction: string;
|
||||||
|
unfeatureAction: string;
|
||||||
|
featureSuccess: string;
|
||||||
|
unfeatureSuccess: string;
|
||||||
deleteAction: string;
|
deleteAction: string;
|
||||||
deleteConfirm: string;
|
deleteConfirm: string;
|
||||||
deleteSuccess: string;
|
deleteSuccess: string;
|
||||||
|
|||||||
@@ -456,6 +456,8 @@ export const zhCN: Translations = {
|
|||||||
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
|
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
|
||||||
badgePinned: "钉选",
|
badgePinned: "钉选",
|
||||||
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
|
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
|
||||||
|
badgeFeatured: "置顶",
|
||||||
|
badgeFeaturedTitle: "已置顶,固定显示在列表顶部",
|
||||||
badgeSourceAgent: "Agent 创建",
|
badgeSourceAgent: "Agent 创建",
|
||||||
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||||
badgeSourceUpload: "用户上传",
|
badgeSourceUpload: "用户上传",
|
||||||
@@ -474,6 +476,10 @@ export const zhCN: Translations = {
|
|||||||
unpinAction: "取消钉选",
|
unpinAction: "取消钉选",
|
||||||
pinSuccess: "已钉选",
|
pinSuccess: "已钉选",
|
||||||
unpinSuccess: "已取消钉选",
|
unpinSuccess: "已取消钉选",
|
||||||
|
featureAction: "置顶",
|
||||||
|
unfeatureAction: "取消置顶",
|
||||||
|
featureSuccess: "已置顶",
|
||||||
|
unfeatureSuccess: "已取消置顶",
|
||||||
deleteAction: "删除技能",
|
deleteAction: "删除技能",
|
||||||
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
|
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
|
||||||
deleteSuccess: "技能已删除",
|
deleteSuccess: "技能已删除",
|
||||||
|
|||||||
@@ -298,6 +298,24 @@ export async function setSkillPinned(
|
|||||||
return response.json() as Promise<Skill>;
|
return response.json() as Promise<Skill>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setSkillFeatured(
|
||||||
|
skillName: string,
|
||||||
|
featured: boolean,
|
||||||
|
): Promise<Skill> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/featured`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ featured }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseDetailedError(response, "Failed to update skill featured state");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<Skill>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SkillContentResponse {
|
export interface SkillContentResponse {
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
setSkillDetail,
|
setSkillDetail,
|
||||||
setSkillDisplay,
|
setSkillDisplay,
|
||||||
setSkillPinned,
|
setSkillPinned,
|
||||||
|
setSkillFeatured,
|
||||||
setSkillPublished,
|
setSkillPublished,
|
||||||
setSkillState,
|
setSkillState,
|
||||||
validateSkillUpload,
|
validateSkillUpload,
|
||||||
@@ -201,6 +202,17 @@ export function useSetSkillPinned() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSetSkillFeatured() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ skillName, featured }: { skillName: string; featured: boolean }) =>
|
||||||
|
setSkillFeatured(skillName, featured),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDuplicateSkill() {
|
export function useDuplicateSkill() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export interface Skill {
|
|||||||
// so stale / archived skills appear in dedicated drawers instead.
|
// so stale / archived skills appear in dedicated drawers instead.
|
||||||
state?: SkillState;
|
state?: SkillState;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
|
// 置顶 —— 仅影响列表排序,与 pinned(防自动归档)正交。
|
||||||
|
featured?: boolean;
|
||||||
// Provenance — distinguishes skills created by an agent during a chat
|
// Provenance — distinguishes skills created by an agent during a chat
|
||||||
// (``agent``) from skills the user uploaded / authored manually (``upload``).
|
// (``agent``) from skills the user uploaded / authored manually (``upload``).
|
||||||
source?: SkillSource;
|
source?: SkillSource;
|
||||||
|
|||||||
@@ -991,12 +991,12 @@ export default function ScheduledTasksPage() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
position="popper"
|
position="popper"
|
||||||
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
className="max-h-[40vh] w-[max(var(--radix-select-trigger-width),28rem)] max-w-[min(90vw,40rem)]"
|
||||||
>
|
>
|
||||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<SelectItem key={agent.id} value={agent.id}>
|
<SelectItem key={agent.id} value={agent.id}>
|
||||||
<span className="block truncate">
|
<span className="block whitespace-normal break-words">
|
||||||
{desensitize(agent.name)}
|
{desensitize(agent.name)}
|
||||||
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
|
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ class SkillResponse(BaseModel):
|
|||||||
created_at: datetime | None = Field(default=None, description="Skill creation timestamp when known")
|
created_at: datetime | None = Field(default=None, description="Skill creation timestamp when known")
|
||||||
state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
|
state: str = Field(default="active", description="Lifecycle state: active, stale, or archived")
|
||||||
pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
|
pinned: bool = Field(default=False, description="Whether this skill is pinned (excluded from curation)")
|
||||||
|
featured: bool = Field(default=False, description="Whether this skill is pinned to the top of the list (sort only)")
|
||||||
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
|
source: str = Field(default="upload", description="Origin: 'agent' for agent-created, 'upload' for user-uploaded")
|
||||||
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
|
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
|
||||||
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
||||||
@@ -294,6 +295,7 @@ def _skill_to_response(
|
|||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
state=entry.get("state", "active"),
|
state=entry.get("state", "active"),
|
||||||
pinned=entry.get("pinned", False),
|
pinned=entry.get("pinned", False),
|
||||||
|
featured=entry.get("featured", False),
|
||||||
source=entry.get("source", "upload"),
|
source=entry.get("source", "upload"),
|
||||||
owner_user_id=owner_user_id,
|
owner_user_id=owner_user_id,
|
||||||
published=published,
|
published=published,
|
||||||
@@ -1469,6 +1471,42 @@ async def update_custom_skill_pinned(
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}")
|
raise HTTPException(status_code=500, detail=f"Failed to update pinned: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class SkillFeaturedRequest(BaseModel):
|
||||||
|
"""Request body for pinning a custom skill to the top of the list."""
|
||||||
|
|
||||||
|
featured: bool = Field(..., description="Whether to pin the skill to the top of the list (sort only)")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/skills/custom/{skill_name}/featured",
|
||||||
|
response_model=SkillResponse,
|
||||||
|
summary="Pin or Unpin a Custom Skill to Top",
|
||||||
|
description="Pin a skill to the top of the list (sort only; orthogonal to pinned/anti-archive). Only the owner or admins may invoke.",
|
||||||
|
)
|
||||||
|
async def update_custom_skill_featured(
|
||||||
|
skill_name: str,
|
||||||
|
body: SkillFeaturedRequest,
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
) -> SkillResponse:
|
||||||
|
try:
|
||||||
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
is_admin = _is_admin_user(request)
|
||||||
|
record, _mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||||
|
|
||||||
|
from deerflow.skills.usage import set_featured as _usage_set_featured
|
||||||
|
|
||||||
|
_usage_set_featured(skill_name, body.featured)
|
||||||
|
return _reload_skill_response(skill_name, record, config)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to update featured for %s: %s", skill_name, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update featured: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
@router.get("/skills/custom/{skill_name}/history", response_model=CustomSkillHistoryResponse, summary="Get Custom Skill History")
|
||||||
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
async def get_custom_skill_history(skill_name: str, config: AppConfig = Depends(get_config)) -> CustomSkillHistoryResponse:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -210,10 +210,7 @@ auth_login:
|
|||||||
|
|
||||||
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
|
# 日志系统入口(仅管理员可见):右下角「设置」菜单中会多出一个「日志系统」入口,
|
||||||
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
||||||
log_system:
|
|
||||||
enabled: true
|
|
||||||
url: "https://your-log-system.example.com"
|
|
||||||
label: "日志系统"
|
|
||||||
|
|
||||||
database:
|
database:
|
||||||
backend: sqlite
|
backend: sqlite
|
||||||
@@ -300,3 +297,8 @@ ai_writing:
|
|||||||
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
||||||
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
||||||
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
|
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
|
||||||
|
|
||||||
|
log_system:
|
||||||
|
enabled: true
|
||||||
|
url: "https://your-log-system.example.com"
|
||||||
|
label: "日志系统"
|
||||||
@@ -36,6 +36,7 @@ _DEFAULT_ENTRY: dict = {
|
|||||||
"created_at": None,
|
"created_at": None,
|
||||||
"state": STATE_ACTIVE,
|
"state": STATE_ACTIVE,
|
||||||
"pinned": False,
|
"pinned": False,
|
||||||
|
"featured": False,
|
||||||
"archived_at": None,
|
"archived_at": None,
|
||||||
"source": SOURCE_UPLOAD,
|
"source": SOURCE_UPLOAD,
|
||||||
}
|
}
|
||||||
@@ -141,6 +142,11 @@ def set_pinned(name: str, pinned: bool) -> None:
|
|||||||
_update_entry(name, {"pinned": pinned})
|
_update_entry(name, {"pinned": pinned})
|
||||||
|
|
||||||
|
|
||||||
|
def set_featured(name: str, featured: bool) -> None:
|
||||||
|
"""置顶/取消置顶。与 pinned(防归档)正交,仅影响列表排序展示。"""
|
||||||
|
_update_entry(name, {"featured": featured})
|
||||||
|
|
||||||
|
|
||||||
def forget(name: str) -> None:
|
def forget(name: str) -> None:
|
||||||
try:
|
try:
|
||||||
with _usage_lock:
|
with _usage_lock:
|
||||||
|
|||||||
Reference in new issue
Block a user