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:
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"},
|
||||
|
||||
Reference in new issue
Block a user