|
@@ -1,11 +1,26 @@
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { useEffect, useMemo, useState } from "react"
|
|
|
|
|
+import { open, save } from "@tauri-apps/plugin-dialog"
|
|
|
|
|
+import { readFile, writeFile } from "@/commands/fs"
|
|
|
import {
|
|
import {
|
|
|
createBlankWritingSkill,
|
|
createBlankWritingSkill,
|
|
|
|
|
+ createSkillCategory,
|
|
|
deleteWritingSkill,
|
|
deleteWritingSkill,
|
|
|
|
|
+ deleteSkillCategory,
|
|
|
|
|
+ exportSkillToJson,
|
|
|
|
|
+ importLinkedSkill,
|
|
|
|
|
+ importSkillFromJson,
|
|
|
|
|
+ importWritingSkill,
|
|
|
|
|
+ loadAllLinkedSkillsContent,
|
|
|
|
|
+ loadLinkedSkillContent,
|
|
|
loadUserSkillConfig,
|
|
loadUserSkillConfig,
|
|
|
|
|
+ moveSkillToCategory,
|
|
|
|
|
+ normalizeUserSkillConfig,
|
|
|
|
|
+ renameSkillCategory,
|
|
|
|
|
+ reorderSkillCategories,
|
|
|
resolveEnabledWritingSkills,
|
|
resolveEnabledWritingSkills,
|
|
|
saveUserSkillConfig,
|
|
saveUserSkillConfig,
|
|
|
setWritingSkillEnabled,
|
|
setWritingSkillEnabled,
|
|
|
|
|
+ touchSkillUsage,
|
|
|
updateWritingSkill,
|
|
updateWritingSkill,
|
|
|
WRITING_SKILL_KIND_OPTIONS,
|
|
WRITING_SKILL_KIND_OPTIONS,
|
|
|
WRITING_SKILL_MODE_OPTIONS,
|
|
WRITING_SKILL_MODE_OPTIONS,
|
|
@@ -16,12 +31,28 @@ import {
|
|
|
SKILL_KIND_LABELS,
|
|
SKILL_KIND_LABELS,
|
|
|
SKILL_MODE_LABELS,
|
|
SKILL_MODE_LABELS,
|
|
|
SKILL_STAGE_LABELS,
|
|
SKILL_STAGE_LABELS,
|
|
|
|
|
+ type SkillCategory,
|
|
|
type SkillKind,
|
|
type SkillKind,
|
|
|
type SkillMode,
|
|
type SkillMode,
|
|
|
type SkillStage,
|
|
type SkillStage,
|
|
|
type UserSkill,
|
|
type UserSkill,
|
|
|
} from "@/lib/novel/skill-library"
|
|
} from "@/lib/novel/skill-library"
|
|
|
import { confirmDiscardSkillLibraryDraft, useWikiStore } from "@/stores/wiki-store"
|
|
import { confirmDiscardSkillLibraryDraft, useWikiStore } from "@/stores/wiki-store"
|
|
|
|
|
+import { GripVertical, Pencil, Trash2 } from "lucide-react"
|
|
|
|
|
+import {
|
|
|
|
|
+ DndContext,
|
|
|
|
|
+ closestCenter,
|
|
|
|
|
+ PointerSensor,
|
|
|
|
|
+ useSensor,
|
|
|
|
|
+ useSensors,
|
|
|
|
|
+ type DragEndEvent,
|
|
|
|
|
+} from "@dnd-kit/core"
|
|
|
|
|
+import {
|
|
|
|
|
+ SortableContext,
|
|
|
|
|
+ useSortable,
|
|
|
|
|
+ verticalListSortingStrategy,
|
|
|
|
|
+} from "@dnd-kit/sortable"
|
|
|
|
|
+import { CSS } from "@dnd-kit/utilities"
|
|
|
|
|
|
|
|
function resolveInitialSkillId(config: UserSkillConfig, requested: string | null): string | null {
|
|
function resolveInitialSkillId(config: UserSkillConfig, requested: string | null): string | null {
|
|
|
if (requested && config.skills.some((skill) => skill.id === requested)) return requested
|
|
if (requested && config.skills.some((skill) => skill.id === requested)) return requested
|
|
@@ -36,6 +67,9 @@ function hasDraftChanged(
|
|
|
kind: SkillKind[],
|
|
kind: SkillKind[],
|
|
|
stages: SkillStage[],
|
|
stages: SkillStage[],
|
|
|
modes: SkillMode[],
|
|
modes: SkillMode[],
|
|
|
|
|
+ priority: number,
|
|
|
|
|
+ tags: string[],
|
|
|
|
|
+ categoryId: string,
|
|
|
): boolean {
|
|
): boolean {
|
|
|
return name.trim() !== skill.name
|
|
return name.trim() !== skill.name
|
|
|
|| description.trim() !== skill.description
|
|
|| description.trim() !== skill.description
|
|
@@ -43,6 +77,9 @@ function hasDraftChanged(
|
|
|
|| kind.join("|") !== skill.kind.join("|")
|
|
|| kind.join("|") !== skill.kind.join("|")
|
|
|
|| stages.join("|") !== skill.stages.join("|")
|
|
|| stages.join("|") !== skill.stages.join("|")
|
|
|
|| modes.join("|") !== skill.modes.join("|")
|
|
|| modes.join("|") !== skill.modes.join("|")
|
|
|
|
|
+ || priority !== skill.priority
|
|
|
|
|
+ || tags.join("|") !== skill.tags.join("|")
|
|
|
|
|
+ || categoryId !== skill.categoryId
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function toggleValue<T extends string>(values: T[], value: T): T[] {
|
|
function toggleValue<T extends string>(values: T[], value: T): T[] {
|
|
@@ -89,9 +126,18 @@ function useWritingSkillConfig() {
|
|
|
setConfig(null)
|
|
setConfig(null)
|
|
|
setLoadError("")
|
|
setLoadError("")
|
|
|
loadUserSkillConfig(projectPath)
|
|
loadUserSkillConfig(projectPath)
|
|
|
- .then((loaded) => {
|
|
|
|
|
|
|
+ .then(async (loaded) => {
|
|
|
if (cancelled) return
|
|
if (cancelled) return
|
|
|
- setConfig(loaded)
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ const withLinkedContent = await loadAllLinkedSkillsContent(loaded)
|
|
|
|
|
+ if (!cancelled) {
|
|
|
|
|
+ setConfig(withLinkedContent)
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ if (!cancelled) {
|
|
|
|
|
+ setConfig(loaded)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
setSelectedSkillId(resolveInitialSkillId(loaded, selectedSkillId))
|
|
setSelectedSkillId(resolveInitialSkillId(loaded, selectedSkillId))
|
|
|
})
|
|
})
|
|
|
.catch(() => {
|
|
.catch(() => {
|
|
@@ -107,6 +153,125 @@ function useWritingSkillConfig() {
|
|
|
return { config, setConfig, loadError }
|
|
return { config, setConfig, loadError }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+interface SortableCategoryItemProps {
|
|
|
|
|
+ category: SkillCategory
|
|
|
|
|
+ count: number
|
|
|
|
|
+ isSelected: boolean
|
|
|
|
|
+ isEditing: boolean
|
|
|
|
|
+ isHovered: boolean
|
|
|
|
|
+ editingCategoryName: string
|
|
|
|
|
+ onSelect: () => void
|
|
|
|
|
+ onStartEdit: () => void
|
|
|
|
|
+ onRename: () => void
|
|
|
|
|
+ onCancelEdit: () => void
|
|
|
|
|
+ onEditingNameChange: (name: string) => void
|
|
|
|
|
+ onDelete: () => void
|
|
|
|
|
+ onMouseEnter: () => void
|
|
|
|
|
+ onMouseLeave: () => void
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function SortableCategoryItem({
|
|
|
|
|
+ category,
|
|
|
|
|
+ count,
|
|
|
|
|
+ isSelected,
|
|
|
|
|
+ isEditing,
|
|
|
|
|
+ isHovered,
|
|
|
|
|
+ editingCategoryName,
|
|
|
|
|
+ onSelect,
|
|
|
|
|
+ onStartEdit,
|
|
|
|
|
+ onRename,
|
|
|
|
|
+ onCancelEdit,
|
|
|
|
|
+ onEditingNameChange,
|
|
|
|
|
+ onDelete,
|
|
|
|
|
+ onMouseEnter,
|
|
|
|
|
+ onMouseLeave,
|
|
|
|
|
+}: SortableCategoryItemProps) {
|
|
|
|
|
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: category.id })
|
|
|
|
|
+ const style = {
|
|
|
|
|
+ transform: CSS.Transform.toString(transform),
|
|
|
|
|
+ transition,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ ref={setNodeRef}
|
|
|
|
|
+ style={style}
|
|
|
|
|
+ onMouseEnter={onMouseEnter}
|
|
|
|
|
+ onMouseLeave={onMouseLeave}
|
|
|
|
|
+ className={`group relative ${isDragging ? "z-10 opacity-80 shadow-md" : ""}`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {isEditing ? (
|
|
|
|
|
+ <div className="flex items-center gap-1 rounded-md px-2 py-1">
|
|
|
|
|
+ <input
|
|
|
|
|
+ autoFocus
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={editingCategoryName}
|
|
|
|
|
+ onChange={(e) => onEditingNameChange(e.target.value)}
|
|
|
|
|
+ onKeyDown={(e) => {
|
|
|
|
|
+ if (e.key === "Enter") {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ onRename()
|
|
|
|
|
+ } else if (e.key === "Escape") {
|
|
|
|
|
+ onCancelEdit()
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ onBlur={onRename}
|
|
|
|
|
+ className="flex-1 rounded-md border bg-background px-2 py-1 text-sm outline-none focus:ring-2 focus:ring-ring"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div
|
|
|
|
|
+ role="button"
|
|
|
|
|
+ tabIndex={0}
|
|
|
|
|
+ onClick={onSelect}
|
|
|
|
|
+ onKeyDown={(e) => {
|
|
|
|
|
+ if (e.key === "Enter" || e.key === " ") {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ onSelect()
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent ${
|
|
|
|
|
+ isSelected ? "bg-accent/60" : ""
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ {...attributes}
|
|
|
|
|
+ {...listeners}
|
|
|
|
|
+ className="cursor-grab text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
|
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
|
|
+ title="拖拽排序"
|
|
|
|
|
+ >
|
|
|
|
|
+ <GripVertical className="h-4 w-4" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <span className="flex-1 truncate">{category.name}</span>
|
|
|
|
|
+ <span className="text-xs text-muted-foreground">{count}</span>
|
|
|
|
|
+ {(isHovered || isSelected) && (
|
|
|
|
|
+ <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={onStartEdit}
|
|
|
|
|
+ className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
|
|
|
|
+ title="重命名"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Pencil className="h-3.5 w-3.5" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={onDelete}
|
|
|
|
|
+ className="rounded p-0.5 text-muted-foreground hover:text-destructive"
|
|
|
|
|
+ title="删除"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export function WritingSkillLibrarySidebarPanel() {
|
|
export function WritingSkillLibrarySidebarPanel() {
|
|
|
const project = useWikiStore((s) => s.project)
|
|
const project = useWikiStore((s) => s.project)
|
|
|
const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
|
|
const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
|
|
@@ -117,9 +282,52 @@ export function WritingSkillLibrarySidebarPanel() {
|
|
|
const { config, setConfig, loadError } = useWritingSkillConfig()
|
|
const { config, setConfig, loadError } = useWritingSkillConfig()
|
|
|
const [message, setMessage] = useState("")
|
|
const [message, setMessage] = useState("")
|
|
|
const [saving, setSaving] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
+ const [selectedCategoryId, setSelectedCategoryId] = useState<string>("all")
|
|
|
|
|
+ const [showNewCategoryInput, setShowNewCategoryInput] = useState(false)
|
|
|
|
|
+ const [newCategoryName, setNewCategoryName] = useState("")
|
|
|
|
|
+ const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null)
|
|
|
|
|
+ const [editingCategoryName, setEditingCategoryName] = useState("")
|
|
|
|
|
+ const [hoveredCategoryId, setHoveredCategoryId] = useState<string | null>(null)
|
|
|
|
|
+
|
|
|
|
|
+ const sensors = useSensors(
|
|
|
|
|
+ useSensor(PointerSensor, {
|
|
|
|
|
+ activationConstraint: { distance: 5 },
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ const categoryIds = useMemo(
|
|
|
|
|
+ () => config?.categories.map((cat) => cat.id) ?? [],
|
|
|
|
|
+ [config?.categories],
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
const disabledSkillIds = new Set(config?.disabledSkillIds ?? [])
|
|
const disabledSkillIds = new Set(config?.disabledSkillIds ?? [])
|
|
|
|
|
|
|
|
|
|
+ const recentSkills = useMemo(() => {
|
|
|
|
|
+ if (!config) return []
|
|
|
|
|
+ return config.skills
|
|
|
|
|
+ .filter((s) => s.source !== "built-in")
|
|
|
|
|
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
|
|
|
|
+ .slice(0, 5)
|
|
|
|
|
+ }, [config])
|
|
|
|
|
+
|
|
|
|
|
+ const filteredSkills = useMemo(() => {
|
|
|
|
|
+ if (!config) return []
|
|
|
|
|
+ if (selectedCategoryId === "all") return config.skills
|
|
|
|
|
+ if (selectedCategoryId === "uncategorized") return config.skills.filter((s) => !s.categoryId)
|
|
|
|
|
+ if (selectedCategoryId === "recent") return recentSkills
|
|
|
|
|
+ return config.skills.filter((s) => s.categoryId === selectedCategoryId)
|
|
|
|
|
+ }, [config, selectedCategoryId, recentSkills])
|
|
|
|
|
+
|
|
|
|
|
+ async function handleDragEnd(event: DragEndEvent) {
|
|
|
|
|
+ const { active, over } = event
|
|
|
|
|
+ if (!over || active.id === over.id || !config || !project || saving) return
|
|
|
|
|
+ const oldIndex = config.categories.findIndex((cat) => cat.id === active.id)
|
|
|
|
|
+ const newIndex = config.categories.findIndex((cat) => cat.id === over.id)
|
|
|
|
|
+ if (oldIndex === -1 || newIndex === -1) return
|
|
|
|
|
+ const next = reorderSkillCategories(config, oldIndex, newIndex)
|
|
|
|
|
+ await persist(next, selectedSkillId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
async function persist(nextConfig: UserSkillConfig, nextSelectedSkillId: string | null) {
|
|
async function persist(nextConfig: UserSkillConfig, nextSelectedSkillId: string | null) {
|
|
|
if (!project) {
|
|
if (!project) {
|
|
|
setMessage("请先打开项目")
|
|
setMessage("请先打开项目")
|
|
@@ -139,12 +347,104 @@ export function WritingSkillLibrarySidebarPanel() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ async function handleSelectSkill(skillId: string) {
|
|
|
|
|
+ if (!config || !project || saving) return
|
|
|
|
|
+ if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
|
|
|
|
|
+ if (draftDirty) setDraftDirty(false)
|
|
|
|
|
+ const next = touchSkillUsage(config, skillId)
|
|
|
|
|
+ await persist(next, skillId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
async function handleCreateSkill() {
|
|
async function handleCreateSkill() {
|
|
|
if (!config || !project || saving) return
|
|
if (!config || !project || saving) return
|
|
|
if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
|
|
if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
|
|
|
if (draftDirty) setDraftDirty(false)
|
|
if (draftDirty) setDraftDirty(false)
|
|
|
const next = createBlankWritingSkill(config)
|
|
const next = createBlankWritingSkill(config)
|
|
|
- await persist(next, next.selectedSkillId)
|
|
|
|
|
|
|
+ if (selectedCategoryId !== "all" && selectedCategoryId !== "uncategorized") {
|
|
|
|
|
+ const withCategory = moveSkillToCategory(next, next.selectedSkillId!, selectedCategoryId)
|
|
|
|
|
+ await persist(withCategory, withCategory.selectedSkillId)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await persist(next, next.selectedSkillId)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleImportSkill() {
|
|
|
|
|
+ if (!config || !project || saving) return
|
|
|
|
|
+ if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
|
|
|
|
|
+ if (draftDirty) setDraftDirty(false)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const selected = await open({
|
|
|
|
|
+ multiple: false,
|
|
|
|
|
+ filters: [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "Skill 文件",
|
|
|
|
|
+ extensions: ["json", "md", "txt"],
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!selected || typeof selected !== "string") return
|
|
|
|
|
+ const content = await readFile(selected)
|
|
|
|
|
+ const fileName = selected.split(/[\\/]/).pop() || "未命名 Skill"
|
|
|
|
|
+ const isJson = /\.json$/i.test(fileName)
|
|
|
|
|
+ let next: UserSkillConfig
|
|
|
|
|
+ if (isJson) {
|
|
|
|
|
+ const imported = importSkillFromJson(content)
|
|
|
|
|
+ if (!imported) {
|
|
|
|
|
+ setMessage("JSON 文件格式不正确,导入失败")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ next = normalizeUserSkillConfig({
|
|
|
|
|
+ ...config,
|
|
|
|
|
+ selectedSkillId: imported.id,
|
|
|
|
|
+ skills: [imported, ...config.skills],
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const nameWithoutExt = fileName.replace(/\.(md|txt)$/i, "")
|
|
|
|
|
+ next = importWritingSkill(config, { name: nameWithoutExt, content })
|
|
|
|
|
+ }
|
|
|
|
|
+ if (selectedCategoryId !== "all" && selectedCategoryId !== "uncategorized") {
|
|
|
|
|
+ next = moveSkillToCategory(next, next.selectedSkillId!, selectedCategoryId)
|
|
|
|
|
+ }
|
|
|
|
|
+ await persist(next, next.selectedSkillId)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ setMessage("导入 Skill 失败")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleImportFolder() {
|
|
|
|
|
+ if (!config || !project || saving) return
|
|
|
|
|
+ if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
|
|
|
|
|
+ if (draftDirty) setDraftDirty(false)
|
|
|
|
|
+ try {
|
|
|
|
|
+ const selected = await open({
|
|
|
|
|
+ multiple: false,
|
|
|
|
|
+ directory: true,
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!selected || typeof selected !== "string") return
|
|
|
|
|
+ let next = await importLinkedSkill(config, selected)
|
|
|
|
|
+ const newSkillId = next.selectedSkillId
|
|
|
|
|
+ if (!newSkillId) {
|
|
|
|
|
+ setMessage("导入失败:文件夹中未找到有效的 Skill 文件")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (selectedCategoryId !== "all" && selectedCategoryId !== "uncategorized") {
|
|
|
|
|
+ next = moveSkillToCategory(next, newSkillId, selectedCategoryId)
|
|
|
|
|
+ }
|
|
|
|
|
+ const newSkill = next.skills.find((s) => s.id === newSkillId)
|
|
|
|
|
+ if (newSkill && newSkill.source === "linked") {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const content = await loadLinkedSkillContent(newSkill)
|
|
|
|
|
+ const updatedSkills = next.skills.map((s) =>
|
|
|
|
|
+ s.id === newSkillId ? { ...s, content } : s
|
|
|
|
|
+ )
|
|
|
|
|
+ next = { ...next, skills: updatedSkills }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ await persist(next, newSkillId)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ setMessage("导入失败:文件夹中未找到有效的 Skill 文件")
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function handleToggleSkill(skill: UserSkill, enabled: boolean) {
|
|
async function handleToggleSkill(skill: UserSkill, enabled: boolean) {
|
|
@@ -155,6 +455,48 @@ export function WritingSkillLibrarySidebarPanel() {
|
|
|
await persist(next, selectedSkillId ?? next.selectedSkillId)
|
|
await persist(next, selectedSkillId ?? next.selectedSkillId)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ async function handleCreateCategory() {
|
|
|
|
|
+ if (!config || !project || saving) return
|
|
|
|
|
+ const trimmed = newCategoryName.trim()
|
|
|
|
|
+ if (!trimmed) {
|
|
|
|
|
+ setShowNewCategoryInput(false)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const next = createSkillCategory(config, trimmed)
|
|
|
|
|
+ setShowNewCategoryInput(false)
|
|
|
|
|
+ setNewCategoryName("")
|
|
|
|
|
+ await persist(next, selectedSkillId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleRenameCategory(categoryId: string) {
|
|
|
|
|
+ if (!config || !project || saving) return
|
|
|
|
|
+ const trimmed = editingCategoryName.trim()
|
|
|
|
|
+ if (!trimmed) {
|
|
|
|
|
+ setEditingCategoryId(null)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const next = renameSkillCategory(config, categoryId, trimmed)
|
|
|
|
|
+ setEditingCategoryId(null)
|
|
|
|
|
+ setEditingCategoryName("")
|
|
|
|
|
+ await persist(next, selectedSkillId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleDeleteCategory(categoryId: string, categoryName: string) {
|
|
|
|
|
+ if (!config || !project || saving) return
|
|
|
|
|
+ const confirmed = window.confirm(`确定删除分类「${categoryName}」吗?删除分类后,分类下的Skill将变为未分类。`)
|
|
|
|
|
+ if (!confirmed) return
|
|
|
|
|
+ const next = deleteSkillCategory(config, categoryId)
|
|
|
|
|
+ if (selectedCategoryId === categoryId) {
|
|
|
|
|
+ setSelectedCategoryId("all")
|
|
|
|
|
+ }
|
|
|
|
|
+ await persist(next, selectedSkillId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function startEditCategory(category: SkillCategory) {
|
|
|
|
|
+ setEditingCategoryId(category.id)
|
|
|
|
|
+ setEditingCategoryName(category.name)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<div data-testid="writing-skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
|
|
<div data-testid="writing-skill-library-sidebar" className="flex h-full flex-col overflow-hidden">
|
|
|
<div className="shrink-0 border-b px-3 py-2">
|
|
<div className="shrink-0 border-b px-3 py-2">
|
|
@@ -162,38 +504,182 @@ export function WritingSkillLibrarySidebarPanel() {
|
|
|
<p className="mt-0.5 text-xs text-muted-foreground">管理 AI 会话自动使用的写作方法。</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">管理 AI 会话自动使用的写作方法。</p>
|
|
|
</div>
|
|
</div>
|
|
|
<div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
|
|
<div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
|
|
|
- <div className="text-sm font-medium">写作 Skill</div>
|
|
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- onClick={() => void handleCreateSkill()}
|
|
|
|
|
- disabled={!config || !project || saving}
|
|
|
|
|
- className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
|
|
|
|
|
|
+ <div className="text-sm font-medium">分类</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="shrink-0 border-b px-2 py-1">
|
|
|
|
|
+ <div
|
|
|
|
|
+ role="button"
|
|
|
|
|
+ tabIndex={0}
|
|
|
|
|
+ onClick={() => setSelectedCategoryId("all")}
|
|
|
|
|
+ onKeyDown={(e) => {
|
|
|
|
|
+ if (e.key === "Enter" || e.key === " ") {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ setSelectedCategoryId("all")
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent ${
|
|
|
|
|
+ selectedCategoryId === "all" ? "bg-accent/60" : ""
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="flex-1 truncate">全部</span>
|
|
|
|
|
+ <span className="text-xs text-muted-foreground">{config?.skills.length ?? 0}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {recentSkills.length > 0 ? (
|
|
|
|
|
+ <div
|
|
|
|
|
+ role="button"
|
|
|
|
|
+ tabIndex={0}
|
|
|
|
|
+ onClick={() => setSelectedCategoryId("recent")}
|
|
|
|
|
+ onKeyDown={(e) => {
|
|
|
|
|
+ if (e.key === "Enter" || e.key === " ") {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ setSelectedCategoryId("recent")
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent ${
|
|
|
|
|
+ selectedCategoryId === "recent" ? "bg-accent/60" : ""
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="flex-1 truncate">最近使用</span>
|
|
|
|
|
+ <span className="text-xs text-muted-foreground">{recentSkills.length}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ <div
|
|
|
|
|
+ role="button"
|
|
|
|
|
+ tabIndex={0}
|
|
|
|
|
+ onClick={() => setSelectedCategoryId("uncategorized")}
|
|
|
|
|
+ onKeyDown={(e) => {
|
|
|
|
|
+ if (e.key === "Enter" || e.key === " ") {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ setSelectedCategoryId("uncategorized")
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent ${
|
|
|
|
|
+ selectedCategoryId === "uncategorized" ? "bg-accent/60" : ""
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="flex-1 truncate">未分类</span>
|
|
|
|
|
+ <span className="text-xs text-muted-foreground">
|
|
|
|
|
+ {config?.skills.filter((s) => !s.categoryId).length ?? 0}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <DndContext
|
|
|
|
|
+ sensors={sensors}
|
|
|
|
|
+ collisionDetection={closestCenter}
|
|
|
|
|
+ onDragEnd={handleDragEnd}
|
|
|
>
|
|
>
|
|
|
- 新建 Skill
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+ <SortableContext items={categoryIds} strategy={verticalListSortingStrategy}>
|
|
|
|
|
+ {config?.categories.map((category) => {
|
|
|
|
|
+ const count = config.skills.filter((s) => s.categoryId === category.id).length
|
|
|
|
|
+ const isEditing = editingCategoryId === category.id
|
|
|
|
|
+ const isHovered = hoveredCategoryId === category.id
|
|
|
|
|
+ return (
|
|
|
|
|
+ <SortableCategoryItem
|
|
|
|
|
+ key={category.id}
|
|
|
|
|
+ category={category}
|
|
|
|
|
+ count={count}
|
|
|
|
|
+ isSelected={selectedCategoryId === category.id}
|
|
|
|
|
+ isEditing={isEditing}
|
|
|
|
|
+ isHovered={isHovered}
|
|
|
|
|
+ editingCategoryName={editingCategoryName}
|
|
|
|
|
+ onSelect={() => setSelectedCategoryId(category.id)}
|
|
|
|
|
+ onStartEdit={() => startEditCategory(category)}
|
|
|
|
|
+ onRename={() => void handleRenameCategory(category.id)}
|
|
|
|
|
+ onCancelEdit={() => setEditingCategoryId(null)}
|
|
|
|
|
+ onEditingNameChange={(name) => setEditingCategoryName(name)}
|
|
|
|
|
+ onDelete={() => void handleDeleteCategory(category.id, category.name)}
|
|
|
|
|
+ onMouseEnter={() => setHoveredCategoryId(category.id)}
|
|
|
|
|
+ onMouseLeave={() => setHoveredCategoryId(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ )
|
|
|
|
|
+ })}
|
|
|
|
|
+ </SortableContext>
|
|
|
|
|
+ </DndContext>
|
|
|
|
|
+ {showNewCategoryInput ? (
|
|
|
|
|
+ <div className="mt-1 flex items-center gap-1 rounded-md px-2 py-1">
|
|
|
|
|
+ <input
|
|
|
|
|
+ autoFocus
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={newCategoryName}
|
|
|
|
|
+ onChange={(e) => setNewCategoryName(e.target.value)}
|
|
|
|
|
+ onKeyDown={(e) => {
|
|
|
|
|
+ if (e.key === "Enter") {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ void handleCreateCategory()
|
|
|
|
|
+ } else if (e.key === "Escape") {
|
|
|
|
|
+ setShowNewCategoryInput(false)
|
|
|
|
|
+ setNewCategoryName("")
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ onBlur={() => void handleCreateCategory()}
|
|
|
|
|
+ placeholder="输入分类名称"
|
|
|
|
|
+ className="flex-1 rounded-md border bg-background px-2 py-1 text-sm outline-none focus:ring-2 focus:ring-ring"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setShowNewCategoryInput(true)
|
|
|
|
|
+ setNewCategoryName("")
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="mt-1 w-full rounded-md px-2 py-1.5 text-left text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
|
|
|
+ >
|
|
|
|
|
+ + 新建分类
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
|
|
|
|
|
+ <div className="text-sm font-medium">写作 Skill</div>
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => void handleImportSkill()}
|
|
|
|
|
+ disabled={!config || !project || saving}
|
|
|
|
|
+ className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ 导入文件
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => void handleImportFolder()}
|
|
|
|
|
+ disabled={!config || !project || saving}
|
|
|
|
|
+ className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ 导入文件夹
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => void handleCreateSkill()}
|
|
|
|
|
+ disabled={!config || !project || saving}
|
|
|
|
|
+ className="rounded-md border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ 新建 Skill
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
{loadError || message ? (
|
|
{loadError || message ? (
|
|
|
<div className="border-b px-3 py-2 text-xs text-muted-foreground">{loadError || message}</div>
|
|
<div className="border-b px-3 py-2 text-xs text-muted-foreground">{loadError || message}</div>
|
|
|
) : null}
|
|
) : null}
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
|
|
- {config && config.skills.length === 0 ? (
|
|
|
|
|
|
|
+ {config && filteredSkills.length === 0 ? (
|
|
|
<div className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
|
|
<div className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
|
|
|
还没有写作 Skill。可以新建“三翻四抖”“章节计划”“伏笔检查”等规则。
|
|
还没有写作 Skill。可以新建“三翻四抖”“章节计划”“伏笔检查”等规则。
|
|
|
</div>
|
|
</div>
|
|
|
) : null}
|
|
) : null}
|
|
|
- {config?.skills.map((skill) => {
|
|
|
|
|
|
|
+ {filteredSkills.map((skill) => {
|
|
|
const active = skill.id === selectedSkillId
|
|
const active = skill.id === selectedSkillId
|
|
|
const enabled = !disabledSkillIds.has(skill.id)
|
|
const enabled = !disabledSkillIds.has(skill.id)
|
|
|
|
|
+ const isLinked = skill.source === "linked"
|
|
|
return (
|
|
return (
|
|
|
<div
|
|
<div
|
|
|
key={skill.id}
|
|
key={skill.id}
|
|
|
role="button"
|
|
role="button"
|
|
|
tabIndex={0}
|
|
tabIndex={0}
|
|
|
- onClick={() => setSelectedSkillId(skill.id)}
|
|
|
|
|
|
|
+ onClick={() => void handleSelectSkill(skill.id)}
|
|
|
onKeyDown={(event) => {
|
|
onKeyDown={(event) => {
|
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
|
event.preventDefault()
|
|
event.preventDefault()
|
|
|
- setSelectedSkillId(skill.id)
|
|
|
|
|
|
|
+ void handleSelectSkill(skill.id)
|
|
|
}
|
|
}
|
|
|
}}
|
|
}}
|
|
|
className={`mb-2 rounded-md border px-3 py-2 text-left transition-colors hover:bg-accent ${
|
|
className={`mb-2 rounded-md border px-3 py-2 text-left transition-colors hover:bg-accent ${
|
|
@@ -203,6 +689,9 @@ export function WritingSkillLibrarySidebarPanel() {
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-2">
|
|
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{skill.name}</span>
|
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{skill.name}</span>
|
|
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">写作</span>
|
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">写作</span>
|
|
|
|
|
+ {isLinked ? (
|
|
|
|
|
+ <span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">引用</span>
|
|
|
|
|
+ ) : null}
|
|
|
</div>
|
|
</div>
|
|
|
<div className="mt-1 truncate text-xs text-muted-foreground">{skill.description || "未填写说明"}</div>
|
|
<div className="mt-1 truncate text-xs text-muted-foreground">{skill.description || "未填写说明"}</div>
|
|
|
<label className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
|
<label className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
|
@@ -238,6 +727,10 @@ export function WritingSkillLibraryView() {
|
|
|
const [draftKind, setDraftKind] = useState<SkillKind[]>([])
|
|
const [draftKind, setDraftKind] = useState<SkillKind[]>([])
|
|
|
const [draftStages, setDraftStages] = useState<SkillStage[]>([])
|
|
const [draftStages, setDraftStages] = useState<SkillStage[]>([])
|
|
|
const [draftModes, setDraftModes] = useState<SkillMode[]>([])
|
|
const [draftModes, setDraftModes] = useState<SkillMode[]>([])
|
|
|
|
|
+ const [draftPriority, setDraftPriority] = useState(50)
|
|
|
|
|
+ const [draftTags, setDraftTags] = useState<string[]>([])
|
|
|
|
|
+ const [draftCategoryId, setDraftCategoryId] = useState("")
|
|
|
|
|
+ const [tagInput, setTagInput] = useState("")
|
|
|
const [message, setMessage] = useState("")
|
|
const [message, setMessage] = useState("")
|
|
|
const [saving, setSaving] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
|
|
@@ -250,18 +743,32 @@ export function WritingSkillLibraryView() {
|
|
|
selectedSkillId: null,
|
|
selectedSkillId: null,
|
|
|
disabledSkillIds: [],
|
|
disabledSkillIds: [],
|
|
|
skills: [],
|
|
skills: [],
|
|
|
|
|
+ categories: [],
|
|
|
}).map((skill) => skill.id))
|
|
}).map((skill) => skill.id))
|
|
|
const selectedEnabled = selectedSkill ? enabledSkillIds.has(selectedSkill.id) : false
|
|
const selectedEnabled = selectedSkill ? enabledSkillIds.has(selectedSkill.id) : false
|
|
|
|
|
+ const isLinkedSkill = selectedSkill?.source === "linked"
|
|
|
const draftChanged = Boolean(
|
|
const draftChanged = Boolean(
|
|
|
- selectedSkill && hasDraftChanged(
|
|
|
|
|
- selectedSkill,
|
|
|
|
|
- draftName,
|
|
|
|
|
- draftDescription,
|
|
|
|
|
- draftContent,
|
|
|
|
|
- draftKind,
|
|
|
|
|
- draftStages,
|
|
|
|
|
- draftModes,
|
|
|
|
|
- ),
|
|
|
|
|
|
|
+ selectedSkill && (isLinkedSkill
|
|
|
|
|
+ ? (draftName.trim() !== selectedSkill.name
|
|
|
|
|
+ || draftDescription.trim() !== selectedSkill.description
|
|
|
|
|
+ || draftKind.join("|") !== selectedSkill.kind.join("|")
|
|
|
|
|
+ || draftStages.join("|") !== selectedSkill.stages.join("|")
|
|
|
|
|
+ || draftModes.join("|") !== selectedSkill.modes.join("|")
|
|
|
|
|
+ || draftPriority !== selectedSkill.priority
|
|
|
|
|
+ || draftTags.join("|") !== selectedSkill.tags.join("|")
|
|
|
|
|
+ || draftCategoryId !== selectedSkill.categoryId)
|
|
|
|
|
+ : hasDraftChanged(
|
|
|
|
|
+ selectedSkill,
|
|
|
|
|
+ draftName,
|
|
|
|
|
+ draftDescription,
|
|
|
|
|
+ draftContent,
|
|
|
|
|
+ draftKind,
|
|
|
|
|
+ draftStages,
|
|
|
|
|
+ draftModes,
|
|
|
|
|
+ draftPriority,
|
|
|
|
|
+ draftTags,
|
|
|
|
|
+ draftCategoryId,
|
|
|
|
|
+ )),
|
|
|
)
|
|
)
|
|
|
const canSaveDraft = Boolean(project && config && selectedSkill && draftChanged && !saving)
|
|
const canSaveDraft = Boolean(project && config && selectedSkill && draftChanged && !saving)
|
|
|
|
|
|
|
@@ -273,6 +780,9 @@ export function WritingSkillLibraryView() {
|
|
|
setDraftKind([])
|
|
setDraftKind([])
|
|
|
setDraftStages([])
|
|
setDraftStages([])
|
|
|
setDraftModes([])
|
|
setDraftModes([])
|
|
|
|
|
+ setDraftPriority(50)
|
|
|
|
|
+ setDraftTags([])
|
|
|
|
|
+ setDraftCategoryId("")
|
|
|
setDraftDirty(false)
|
|
setDraftDirty(false)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -282,6 +792,9 @@ export function WritingSkillLibraryView() {
|
|
|
setDraftKind(selectedSkill.kind)
|
|
setDraftKind(selectedSkill.kind)
|
|
|
setDraftStages(selectedSkill.stages)
|
|
setDraftStages(selectedSkill.stages)
|
|
|
setDraftModes(selectedSkill.modes)
|
|
setDraftModes(selectedSkill.modes)
|
|
|
|
|
+ setDraftPriority(selectedSkill.priority)
|
|
|
|
|
+ setDraftTags(selectedSkill.tags)
|
|
|
|
|
+ setDraftCategoryId(selectedSkill.categoryId)
|
|
|
setDraftDirty(false)
|
|
setDraftDirty(false)
|
|
|
setMessage("")
|
|
setMessage("")
|
|
|
}, [selectedSkill?.id, selectedSkill?.name, selectedSkill?.description, selectedSkill?.content])
|
|
}, [selectedSkill?.id, selectedSkill?.name, selectedSkill?.description, selectedSkill?.content])
|
|
@@ -293,9 +806,12 @@ export function WritingSkillLibraryView() {
|
|
|
kind = draftKind,
|
|
kind = draftKind,
|
|
|
stages = draftStages,
|
|
stages = draftStages,
|
|
|
modes = draftModes,
|
|
modes = draftModes,
|
|
|
|
|
+ priority = draftPriority,
|
|
|
|
|
+ tags = draftTags,
|
|
|
|
|
+ categoryId = draftCategoryId,
|
|
|
) {
|
|
) {
|
|
|
setDraftDirty(selectedSkill
|
|
setDraftDirty(selectedSkill
|
|
|
- ? hasDraftChanged(selectedSkill, name, description, content, kind, stages, modes)
|
|
|
|
|
|
|
+ ? hasDraftChanged(selectedSkill, name, description, content, kind, stages, modes, priority, tags, categoryId)
|
|
|
: false)
|
|
: false)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -322,23 +838,31 @@ export function WritingSkillLibraryView() {
|
|
|
async function handleSaveSkill() {
|
|
async function handleSaveSkill() {
|
|
|
if (!config || !selectedSkill || !canSaveDraft) return
|
|
if (!config || !selectedSkill || !canSaveDraft) return
|
|
|
const name = draftName.trim()
|
|
const name = draftName.trim()
|
|
|
- const content = draftContent.trim()
|
|
|
|
|
if (!name) {
|
|
if (!name) {
|
|
|
setMessage("Skill 名称不能为空")
|
|
setMessage("Skill 名称不能为空")
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
- if (!content) {
|
|
|
|
|
- setMessage("规则正文不能为空")
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ if (!isLinkedSkill) {
|
|
|
|
|
+ const content = draftContent.trim()
|
|
|
|
|
+ if (!content) {
|
|
|
|
|
+ setMessage("规则正文不能为空")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- await persist(updateWritingSkill(config, selectedSkill.id, {
|
|
|
|
|
|
|
+ const patch: Partial<Pick<UserSkill, "name" | "description" | "kind" | "stages" | "modes" | "content" | "priority" | "tags" | "categoryId">> = {
|
|
|
name,
|
|
name,
|
|
|
description: draftDescription.trim(),
|
|
description: draftDescription.trim(),
|
|
|
- content,
|
|
|
|
|
kind: draftKind,
|
|
kind: draftKind,
|
|
|
stages: draftStages,
|
|
stages: draftStages,
|
|
|
modes: draftModes,
|
|
modes: draftModes,
|
|
|
- }))
|
|
|
|
|
|
|
+ priority: draftPriority,
|
|
|
|
|
+ tags: draftTags,
|
|
|
|
|
+ categoryId: draftCategoryId,
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!isLinkedSkill) {
|
|
|
|
|
+ patch.content = draftContent.trim()
|
|
|
|
|
+ }
|
|
|
|
|
+ await persist(updateWritingSkill(config, selectedSkill.id, patch))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function handleToggleEnabled(enabled: boolean) {
|
|
async function handleToggleEnabled(enabled: boolean) {
|
|
@@ -352,12 +876,75 @@ export function WritingSkillLibraryView() {
|
|
|
if (!config || !selectedSkill || !project || saving) return
|
|
if (!config || !selectedSkill || !project || saving) return
|
|
|
if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
|
|
if (draftDirty && !confirmDiscardSkillLibraryDraft()) return
|
|
|
if (draftDirty) setDraftDirty(false)
|
|
if (draftDirty) setDraftDirty(false)
|
|
|
- const confirmed = window.confirm(`确定删除「${selectedSkill.name}」吗?`)
|
|
|
|
|
|
|
+ const confirmed = window.confirm(`确定删除「${selectedSkill.name}」吗?删除后无法恢复。`)
|
|
|
if (!confirmed) return
|
|
if (!confirmed) return
|
|
|
const next = deleteWritingSkill(config, selectedSkill.id)
|
|
const next = deleteWritingSkill(config, selectedSkill.id)
|
|
|
await persist(next, next.selectedSkillId)
|
|
await persist(next, next.selectedSkillId)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ async function handleExportSkill() {
|
|
|
|
|
+ if (!selectedSkill) return
|
|
|
|
|
+ try {
|
|
|
|
|
+ const jsonStr = exportSkillToJson(selectedSkill)
|
|
|
|
|
+ const filePath = await save({
|
|
|
|
|
+ defaultPath: `${selectedSkill.name}.json`,
|
|
|
|
|
+ filters: [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "JSON 文件",
|
|
|
|
|
+ extensions: ["json"],
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!filePath) return
|
|
|
|
|
+ await writeFile(filePath, jsonStr)
|
|
|
|
|
+ setMessage("导出成功")
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ setMessage("导出 Skill 失败")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleReloadLinkedContent() {
|
|
|
|
|
+ if (!selectedSkill || selectedSkill.source !== "linked" || !config) return
|
|
|
|
|
+ try {
|
|
|
|
|
+ const content = await loadLinkedSkillContent(selectedSkill)
|
|
|
|
|
+ const updatedSkill = { ...selectedSkill, content }
|
|
|
|
|
+ const updatedSkills = config.skills.map((s) =>
|
|
|
|
|
+ s.id === selectedSkill.id ? updatedSkill : s
|
|
|
|
|
+ )
|
|
|
|
|
+ setConfig({ ...config, skills: updatedSkills })
|
|
|
|
|
+ setDraftContent(content)
|
|
|
|
|
+ setMessage("已重新读取内容")
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ setMessage("读取内容失败")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleAddTag() {
|
|
|
|
|
+ const trimmed = tagInput.trim()
|
|
|
|
|
+ if (!trimmed) return
|
|
|
|
|
+ if (draftTags.includes(trimmed)) {
|
|
|
|
|
+ setTagInput("")
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const next = [...draftTags, trimmed]
|
|
|
|
|
+ setDraftTags(next)
|
|
|
|
|
+ setTagInput("")
|
|
|
|
|
+ updateDraftDirty(draftName, draftDescription, draftContent, draftKind, draftStages, draftModes, draftPriority, next)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleRemoveTag(tag: string) {
|
|
|
|
|
+ const next = draftTags.filter((t) => t !== tag)
|
|
|
|
|
+ setDraftTags(next)
|
|
|
|
|
+ updateDraftDirty(draftName, draftDescription, draftContent, draftKind, draftStages, draftModes, draftPriority, next)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleTagKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
|
|
|
|
+ if (event.key === "Enter" || event.key === ",") {
|
|
|
|
|
+ event.preventDefault()
|
|
|
|
|
+ handleAddTag()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<div data-testid="writing-skill-library-view" className="flex h-full flex-col overflow-hidden">
|
|
<div data-testid="writing-skill-library-view" className="flex h-full flex-col overflow-hidden">
|
|
|
<div className="shrink-0 border-b px-5 py-4">
|
|
<div className="shrink-0 border-b px-5 py-4">
|
|
@@ -377,9 +964,14 @@ export function WritingSkillLibraryView() {
|
|
|
) : (
|
|
) : (
|
|
|
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
|
<div className="mx-auto flex max-w-5xl flex-col gap-4">
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
|
- <div>
|
|
|
|
|
- <div className="text-sm text-muted-foreground">项目写作 Skill</div>
|
|
|
|
|
- <h2 className="text-xl font-semibold">{selectedSkill.name}</h2>
|
|
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div className="text-sm text-muted-foreground">项目写作 Skill</div>
|
|
|
|
|
+ <h2 className="text-xl font-semibold">{selectedSkill.name}</h2>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {isLinkedSkill ? (
|
|
|
|
|
+ <span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">引用</span>
|
|
|
|
|
+ ) : null}
|
|
|
</div>
|
|
</div>
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
@@ -393,6 +985,13 @@ export function WritingSkillLibraryView() {
|
|
|
/>
|
|
/>
|
|
|
参与 AI 会话
|
|
参与 AI 会话
|
|
|
</label>
|
|
</label>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => void handleExportSkill()}
|
|
|
|
|
+ className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent disabled:opacity-60"
|
|
|
|
|
+ >
|
|
|
|
|
+ 导出
|
|
|
|
|
+ </button>
|
|
|
<button
|
|
<button
|
|
|
data-testid="writing-skill-delete-button"
|
|
data-testid="writing-skill-delete-button"
|
|
|
type="button"
|
|
type="button"
|
|
@@ -433,6 +1032,24 @@ export function WritingSkillLibraryView() {
|
|
|
/>
|
|
/>
|
|
|
</label>
|
|
</label>
|
|
|
|
|
|
|
|
|
|
+ <label className="grid gap-1.5 text-sm">
|
|
|
|
|
+ <span className="font-medium">分类</span>
|
|
|
|
|
+ <select
|
|
|
|
|
+ value={draftCategoryId}
|
|
|
|
|
+ onChange={(event) => {
|
|
|
|
|
+ const next = event.target.value
|
|
|
|
|
+ setDraftCategoryId(next)
|
|
|
|
|
+ updateDraftDirty(draftName, draftDescription, draftContent, draftKind, draftStages, draftModes, draftPriority, draftTags, next)
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="rounded-md border bg-background px-3 py-2 outline-none focus:ring-2 focus:ring-ring"
|
|
|
|
|
+ >
|
|
|
|
|
+ <option value="">未分类</option>
|
|
|
|
|
+ {config?.categories.map((cat) => (
|
|
|
|
|
+ <option key={cat.id} value={cat.id}>{cat.name}</option>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </label>
|
|
|
|
|
+
|
|
|
<div className="grid gap-2 text-sm">
|
|
<div className="grid gap-2 text-sm">
|
|
|
<span className="font-medium">类型</span>
|
|
<span className="font-medium">类型</span>
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<div className="flex flex-wrap gap-2">
|
|
@@ -491,19 +1108,95 @@ export function WritingSkillLibraryView() {
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<label className="grid gap-1.5 text-sm">
|
|
<label className="grid gap-1.5 text-sm">
|
|
|
- <span className="font-medium">规则正文</span>
|
|
|
|
|
- <textarea
|
|
|
|
|
- data-testid="writing-skill-content-input"
|
|
|
|
|
- value={draftContent}
|
|
|
|
|
|
|
+ <span className="font-medium">优先级(1-100,越小越优先)</span>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="number"
|
|
|
|
|
+ min={1}
|
|
|
|
|
+ max={100}
|
|
|
|
|
+ value={draftPriority}
|
|
|
onChange={(event) => {
|
|
onChange={(event) => {
|
|
|
- const next = event.target.value
|
|
|
|
|
- setDraftContent(next)
|
|
|
|
|
- updateDraftDirty(draftName, draftDescription, next)
|
|
|
|
|
|
|
+ const value = parseInt(event.target.value, 10)
|
|
|
|
|
+ const next = isNaN(value) ? 50 : Math.max(1, Math.min(100, value))
|
|
|
|
|
+ setDraftPriority(next)
|
|
|
|
|
+ updateDraftDirty(draftName, draftDescription, draftContent, draftKind, draftStages, draftModes, next)
|
|
|
}}
|
|
}}
|
|
|
- className="min-h-[420px] rounded-md border bg-background px-3 py-2 font-mono text-xs leading-5 outline-none focus:ring-2 focus:ring-ring"
|
|
|
|
|
|
|
+ className="w-32 rounded-md border bg-background px-3 py-2 outline-none focus:ring-2 focus:ring-ring"
|
|
|
/>
|
|
/>
|
|
|
</label>
|
|
</label>
|
|
|
|
|
|
|
|
|
|
+ <div className="grid gap-2 text-sm">
|
|
|
|
|
+ <span className="font-medium">标签</span>
|
|
|
|
|
+ <div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
+ {draftTags.map((tag) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={tag}
|
|
|
|
|
+ className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5 text-xs"
|
|
|
|
|
+ >
|
|
|
|
|
+ {tag}
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => handleRemoveTag(tag)}
|
|
|
|
|
+ className="text-muted-foreground hover:text-foreground"
|
|
|
|
|
+ >
|
|
|
|
|
+ ×
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={tagInput}
|
|
|
|
|
+ onChange={(event) => setTagInput(event.target.value)}
|
|
|
|
|
+ onKeyDown={handleTagKeyDown}
|
|
|
|
|
+ onBlur={handleAddTag}
|
|
|
|
|
+ placeholder="输入标签后按回车添加"
|
|
|
|
|
+ className="flex-1 min-w-[150px] rounded-md border bg-background px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {isLinkedSkill ? (
|
|
|
|
|
+ <div className="grid gap-1.5 text-sm">
|
|
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
|
|
+ <span className="font-medium">规则正文</span>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => void handleReloadLinkedContent()}
|
|
|
|
|
+ className="rounded-md border px-2 py-1 text-xs hover:bg-accent"
|
|
|
|
|
+ >
|
|
|
|
|
+ 重新读取
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="rounded-md border bg-blue-50 px-3 py-2 text-xs text-blue-700">
|
|
|
|
|
+ 此 Skill 为外部引用,内容实时读取
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ readOnly
|
|
|
|
|
+ value={draftContent}
|
|
|
|
|
+ className="min-h-[420px] resize-y rounded-md border bg-muted px-3 py-2 font-mono text-xs leading-5 outline-none"
|
|
|
|
|
+ />
|
|
|
|
|
+ {selectedSkill.linkedPath ? (
|
|
|
|
|
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
|
|
|
+ <span>引用路径:</span>
|
|
|
|
|
+ <code className="truncate rounded bg-muted px-1.5 py-0.5">{selectedSkill.linkedPath}</code>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <label className="grid gap-1.5 text-sm">
|
|
|
|
|
+ <span className="font-medium">规则正文</span>
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ data-testid="writing-skill-content-input"
|
|
|
|
|
+ value={draftContent}
|
|
|
|
|
+ onChange={(event) => {
|
|
|
|
|
+ const next = event.target.value
|
|
|
|
|
+ setDraftContent(next)
|
|
|
|
|
+ updateDraftDirty(draftName, draftDescription, next)
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="min-h-[420px] rounded-md border bg-background px-3 py-2 font-mono text-xs leading-5 outline-none focus:ring-2 focus:ring-ring"
|
|
|
|
|
+ />
|
|
|
|
|
+ </label>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-3">
|
|
|
<button
|
|
<button
|
|
|
data-testid="writing-skill-save-button"
|
|
data-testid="writing-skill-save-button"
|