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:
吉鑫升andClaude Opus 4.8 committed 2026-06-05 15:02:50 +08:00
1 parent b117bb1dc2
commit eaa061dd06
11 files changed
+318 -171

No files matched your search

@@ -1,10 +1,11 @@
"use client";
import {
AlertCircle,
ArchiveIcon,
ArrowDownUpIcon,
CheckCircle2,
import {
AlertCircle,
ArchiveIcon,
ArrowDownUpIcon,
BookmarkIcon,
CheckCircle2,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
@@ -12,22 +13,22 @@ import {
FileArchive,
GlobeIcon,
InfoIcon,
Loader2,
LockIcon,
PinIcon,
RefreshCwIcon,
Loader2,
LockIcon,
PinIcon,
RefreshCwIcon,
SparklesIcon,
TagsIcon,
Trash2Icon,
TrendingUpIcon,
Upload,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -37,8 +38,8 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import {
Empty,
EmptyDescription,
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
@@ -51,9 +52,9 @@ import {
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
@@ -67,6 +68,7 @@ import {
useDuplicateSkill,
useInstallSkillUpload,
useReconcileCustomSkills,
useSetSkillFeatured,
useSetSkillPinned,
useSetSkillPublished,
useSkills,
@@ -92,16 +94,16 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
);
}
const PAGE_SIZE = 10;
type SkillSort =
| "time_desc"
| "time_asc"
| "activity_desc"
| "activity_asc"
| "usage_desc"
| "usage_asc"
| "name_asc"
| "name_desc";
const PAGE_SIZE = 10;
type SkillSort =
| "time_desc"
| "time_asc"
| "activity_desc"
| "activity_asc"
| "usage_desc"
| "usage_asc"
| "name_asc"
| "name_desc";
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanSuccessToastDarkOptions = {
@@ -136,21 +138,21 @@ const rescanErrorToastDarkOptions = {
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const { theme } = useTheme();
const router = useRouter();
const [filter, setFilter] = useState<string>("mine");
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const { theme } = useTheme();
const router = useRouter();
const [filter, setFilter] = useState<string>("mine");
const [installOpen, setInstallOpen] = useState(false);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc");
const [page, setPage] = useState(1);
const reconcileMutation = useReconcileCustomSkills();
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc");
const [page, setPage] = useState(1);
const reconcileMutation = useReconcileCustomSkills();
// Name + tag filtering is resolved server-side via the /api/skills query.
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -195,36 +197,38 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
};
}, [skills, currentUserId]);
const filteredSkills = filter === "mine" ? activeMine : activeAll;
const orderedSkills = useMemo(
() =>
filteredSkills
.map((skill, index) => ({ skill, index }))
.sort((a, b) => {
const pinnedDelta = Number(!!b.skill.pinned) - Number(!!a.skill.pinned);
return pinnedDelta || a.index - b.index;
})
.map(({ skill }) => skill),
[filteredSkills],
);
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const totalPages = Math.max(1, Math.ceil(orderedSkills.length / PAGE_SIZE));
const filteredSkills = filter === "mine" ? activeMine : activeAll;
const orderedSkills = useMemo(
() =>
filteredSkills
.map((skill, index) => ({ skill, index }))
.sort((a, b) => {
// 置顶(featured)决定排序;钉选(pinned)只管防归档,不影响顺序。
const featuredDelta =
Number(!!b.skill.featured) - Number(!!a.skill.featured);
return featuredDelta || a.index - b.index;
})
.map(({ skill }) => skill),
[filteredSkills],
);
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const totalPages = Math.max(1, Math.ceil(orderedSkills.length / PAGE_SIZE));
// Filter / search / tag changes may shrink the result set — pull the page
// back into range without forcing a reset on every keystroke.
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
useEffect(() => {
setPage(1);
}, [search, tagIds, sort]);
const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page],
);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
useEffect(() => {
setPage(1);
}, [search, tagIds, sort]);
const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page],
);
const handleRescan = async () => {
const handleRescan = async () => {
try {
const result = await reconcileMutation.mutateAsync();
if (result.adopted.length > 0) {
@@ -323,54 +327,54 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
<Upload className="size-4" />
{t.settings.skills.installSkill}
</Button>
</div>
</header>
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="activity_desc">最近使用</SelectItem>
<SelectItem value="activity_asc">最早使用</SelectItem>
<SelectItem value="usage_desc">使用最多</SelectItem>
<SelectItem value="usage_asc">使用最少</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
</div>
</header>
<TagSearchBar
targetType="skill"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<SelectTrigger
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
title="排序"
>
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="time_desc">最新创建</SelectItem>
<SelectItem value="time_asc">最早创建</SelectItem>
<SelectItem value="activity_desc">最近使用</SelectItem>
<SelectItem value="activity_asc">最早使用</SelectItem>
<SelectItem value="usage_desc">使用最多</SelectItem>
<SelectItem value="usage_asc">使用最少</SelectItem>
<SelectItem value="name_asc">名称 A-Z</SelectItem>
<SelectItem value="name_desc">名称 Z-A</SelectItem>
</SelectContent>
</Select>
}
/>
{isLoading && (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
)}
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill />
)}
{!isLoading && !error && filteredSkills.length > 0 && (
<div className="flex flex-col gap-3">
{pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
/>
))}
</div>
)}
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill />
)}
{!isLoading && !error && filteredSkills.length > 0 && (
<div className="flex flex-col gap-3">
{pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
/>
))}
</div>
)}
{!isLoading && !error && totalPages > 1 && (
<div className="flex items-center justify-end gap-2 pt-1">
<Button
@@ -419,18 +423,19 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
);
}
export function SkillRow({
skill,
currentUserId,
isAdmin,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
}) {
export function SkillRow({
skill,
currentUserId,
isAdmin,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
}) {
const { t } = useI18n();
const setPublished = useSetSkillPublished();
const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured();
const deleteSkill = useDeleteCustomSkill();
const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
@@ -510,9 +515,23 @@ 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 (
<>
<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">
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
@@ -577,6 +596,15 @@ export function SkillRow({
{t.settings.skills.badgeSourceUpload}
</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 && (
<Badge
variant="outline"
@@ -618,24 +646,47 @@ export function SkillRow({
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700",
)}
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
<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 && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700",
)}
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
<PinIcon className={cn("size-3.5", skill.pinned && "fill-current")} />
</Button>
)}
: t.settings.skills.pinAction
}
>
<BookmarkIcon
className={cn("size-3.5", skill.pinned && "fill-current")}
/>
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
@@ -678,8 +729,8 @@ export function SkillRow({
</Button>
)}
</ItemActions>
</Item>
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
</Item>
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>复制技能</DialogTitle>
@@ -762,10 +813,10 @@ export function SkillRow({
);
}
function EmptySkill() {
const { t } = useI18n();
return (
<Empty>
function EmptySkill() {
const { t } = useI18n();
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<SparklesIcon />
@@ -775,9 +826,9 @@ function EmptySkill() {
{t.settings.skills.emptyDescription}
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
</Empty>
);
}
function InstallSkillDialog({
open,