Revert skill list layout and tune leaderboard report

- Revert skill settings to vertical list layout with pinned sorting
- Default leaderboard range 30d -> 7d, hide stats while report generating
- Reframe leaderboard report prompt as user-behavior analysis (cache v5)

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 11:34:39 +08:00
1 parent 0d91c74289
commit b117bb1dc2
3 files changed
+81 -294

No files matched your search

@@ -12,11 +12,10 @@ import {
FileArchive,
GlobeIcon,
InfoIcon,
Loader2,
LockIcon,
PinIcon,
PinOffIcon,
RefreshCwIcon,
Loader2,
LockIcon,
PinIcon,
RefreshCwIcon,
SparklesIcon,
TagsIcon,
Trash2Icon,
@@ -27,15 +26,8 @@ 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 {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -203,11 +195,22 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
};
}, [skills, currentUserId]);
const filteredSkills = filter === "mine" ? activeMine : activeAll;
const staleCount = staleVisible.length;
const archivedCount = archivedVisible.length;
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
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));
// Filter / search / tag changes may shrink the result set — pull the page
// back into range without forcing a reset on every keystroke.
useEffect(() => {
@@ -216,10 +219,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
useEffect(() => {
setPage(1);
}, [search, tagIds, sort]);
const pagedSkills = useMemo(
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[filteredSkills, page],
);
const pagedSkills = useMemo(
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[orderedSkills, page],
);
const handleRescan = async () => {
try {
@@ -356,32 +359,18 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill />
)}
{/* —— 原纵向列表布局(保留备查)——
{!isLoading && !error &&
filteredSkills.length > 0 &&
pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
/>
))} */}
{/* card */}
{!isLoading && !error && filteredSkills.length > 0 && (
<div className="grid grid-cols-1 items-stretch gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
className="h-full"
/>
))}
</div>
)}
{!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
@@ -430,17 +419,15 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
);
}
export function SkillRow({
skill,
currentUserId,
isAdmin,
className,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
className?: string;
}) {
export function SkillRow({
skill,
currentUserId,
isAdmin,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
}) {
const { t } = useI18n();
const setPublished = useSetSkillPublished();
const setPinned = useSetSkillPinned();
@@ -525,9 +512,7 @@ export function SkillRow({
return (
<>
{/* —— 原 Item 列表行布局(保留备查)——
<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)}
@@ -633,25 +618,24 @@ export function SkillRow({
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
<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
}
>
{skill.pinned ? (
<PinOffIcon className="size-3.5" />
) : (
<PinIcon className="size-3.5" />
)}
</Button>
)}
: t.settings.skills.pinAction
}
>
<PinIcon className={cn("size-3.5", skill.pinned && "fill-current")} />
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
@@ -694,206 +678,8 @@ export function SkillRow({
</Button>
)}
</ItemActions>
</Item> */}
<Card
className={cn(
"group flex h-full min-w-0 cursor-pointer flex-col transition-shadow hover:shadow-md dark:!border-[#213D5E] dark:bg-[#02244C] dark:text-white",
className,
)}
onClick={() => setDetailOpen(true)}
>
<CardHeader className="min-w-0 flex flex-col gap-0">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<SparklesIcon className="h-5 w-5" />
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 overflow-hidden">
<CardTitle
className="min-w-0 flex-1 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(skill.name)}
>
{desensitize(skill.name)}
</CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs">
{isBuiltin
? t.settings.skills.badgeBuiltin
: skill.category}
</Badge>
</div>
</div>
<div className="mt-4 mb-1.5 flex min-h-5 flex-wrap items-center gap-1">
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
<GlobeIcon className="size-3" />
{t.settings.skills.badgePublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={t.settings.skills.badgeSourceAgentTitle}
>
{t.settings.skills.badgeSourceAgent}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeSourceUploadTitle}
>
{t.settings.skills.badgeSourceUpload}
</Badge>
)}
{skill.pinned && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title={t.settings.skills.badgePinnedTitle}
>
{t.settings.skills.badgePinned}
</Badge>
)}
</div>
<div className="mt-1.5 min-h-10 mb-3">
{skill.description ? (
<CardDescription className="line-clamp-2 text-sm">
{desensitize(skill.description)}
</CardDescription>
) : null}
</div>
<div className="text-muted-foreground mt-0.5 min-h-4 text-xs leading-tight">
{skill.use_count !== undefined && skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
<div
className={cn(
"min-w-0 max-w-full overflow-hidden pb-0",
(skill.tags?.length ?? 0) > 0 ? "pt-2.5" : "min-h-4",
)}
>
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="w-full" />
)}
</div>
</CardHeader>
<CardFooter
className="cursor-default py-0 pt-1.5"
onClick={(e) => e.stopPropagation()}
>
<div className="flex w-full flex-wrap items-center justify-end gap-1">
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
{skill.pinned ? (
<PinOffIcon className="h-3.5 w-3.5" />
) : (
<PinIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<LockIcon className="h-3.5 w-3.5" />
) : (
<GlobeIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardFooter>
</Card>
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
</Item>
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>复制技能</DialogTitle>
@@ -100,7 +100,7 @@ function toDateInput(date: Date): string {
return `${year}-${month}-${day}`;
}
function defaultRange(days = 30): RangeState {
function defaultRange(days = 7): RangeState {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - days + 1);
@@ -108,7 +108,7 @@ function defaultRange(days = 30): RangeState {
}
function rangeForApi(range: RangeState) {
const normalized = range.since && range.until ? range : defaultRange(30);
const normalized = range.since && range.until ? range : defaultRange(7);
const start = new Date(`${normalized.since}T00:00:00`);
const end = new Date(`${normalized.until}T23:59:59`);
return {
@@ -1035,7 +1035,7 @@ function UserDetailPanel({
export default function AdminUserLeaderboardPage() {
const navigate = useNavigate();
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
const [range, setRange] = useState<RangeState>(() => defaultRange(7));
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [groupRules, setGroupRules] = useState("zero,two");
@@ -1288,7 +1288,7 @@ export default function AdminUserLeaderboardPage() {
{reportGenerating ? "生成中" : "生成报告"}
</Button>
</div>
{data && (
{data && reportStatus === "idle" && !reportText && (
<div className="mx-6 mt-4 grid shrink-0 gap-3 md:grid-cols-3">
<section className="rounded-lg border border-[#F1D6D6] bg-white p-3 dark:border-[#462123] dark:bg-[#230D0F]">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[#231815] dark:text-white">
@@ -318,7 +318,7 @@ def _report_cache_key(
settings,
) -> str:
payload = {
"version": 4,
"version": 5,
"since": _iso(since),
"until": _iso(until),
"limit": limit,
@@ -1091,15 +1091,16 @@ async def _build_leaderboard_report_prompt(
"trends": [t.model_dump() for t in trends[-21:]],
}
return (
"请基于以下管理员排行榜统计数据,生成一份中文运营分析报告。"
"请基于以下管理员排行榜统计数据,生成一份中文用户行为分析报告。"
"报告需要有结论,不要只罗列数字;请使用 Markdown 标题、列表和表格,让前端可以直接渲染。"
"重点要求:"
"1) 优先突出用户最近关注的实时问题、事件、主题和业务方向,必须引用 recent_questions 中的近期问题作为依据;"
"2) 单独分析 frequent_questions,归纳高频关注簇,说明这些高频问题反映出的需求强度;"
"3) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户画像;"
"4) 可以提及使用强度、关注变化和运营机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
"5) 给管理员 3-5 条可执行建议,建议应围绕用户关注主题、资源配置、内容供给、培训引导和高频能力优化。"
"输出结构:摘要、最近关注、高频问题与使用频率、重点用户与常用能力、趋势观察、行动建议。"
"1) 你是专门负责分析用户行为的专家,不要自称企业 AI 应用运营分析师;"
"2) 优先突出用户最近常用问题、最近关注的实时问题、事件、主题和业务方向,必须引用 recent_questions 中的近期问题作为依据;"
"3) 单独分析 frequent_questions,归纳高频关注簇,说明这些高频问题反映出的需求强度和重复频率;"
"4) 结合 top_users、top_skills、top_tools、top_models 和 trends,说明使用频率、使用增长、常用能力和重点用户行为画像;"
"5) 可以提及使用强度、关注变化和行为机会,但不要把报告重点放在系统本身的问题、故障、失败率或回答质量缺陷上;"
"6) 给管理员 3-5 条可执行建议,建议应围绕用户关注主题、资源配置、内容供给、培训引导和高频能力优化。"
"输出结构:摘要、最近关注、高频问题与频率、重点用户行为、常用能力、趋势观察、行动建议。"
"\n\n统计数据如下:\n"
f"{json.dumps(context, ensure_ascii=False, default=str)}"
)
@@ -1141,7 +1142,7 @@ async def generate_user_leaderboard_report(
model = create_chat_model(name=resolved_model_name, thinking_enabled=thinking_enabled, app_config=config)
response = await model.ainvoke(
[
SystemMessage(content="你是企业 AI 应用的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告;报告聚焦用户关注主题和使用频率,不讨论系统本身问题。"),
SystemMessage(content="你是专门负责分析用户行为的专家,擅长从用户最近常用问题、关注主题、使用频率和调用指标中生成管理报告;报告聚焦用户行为,不讨论系统本身问题。"),
HumanMessage(content=prompt),
],
config={"run_name": "admin_user_leaderboard_report"},
@@ -1206,7 +1207,7 @@ async def stream_user_leaderboard_report(
model = create_chat_model(name=resolved_model_name, thinking_enabled=thinking_enabled, app_config=config)
async for chunk in model.astream(
[
SystemMessage(content="你是企业 AI 应用的运营分析师,擅长从用户行为、问题文本和调用指标中生成管理报告;报告聚焦用户关注主题和使用频率,不讨论系统本身问题。"),
SystemMessage(content="你是专门负责分析用户行为的专家,擅长从用户最近常用问题、关注主题、使用频率和调用指标中生成管理报告;报告聚焦用户行为,不讨论系统本身问题。"),
HumanMessage(content=prompt),
],
config={"run_name": "admin_user_leaderboard_report"},