作家写作

This commit is contained in:
fengxiangdebaozi committed 2026-05-29 11:42:30 +08:00
1 parent 1a122eb7fd
commit 07ad87365f
7 files changed
+380 -228

No files matched your search

+89 -2
View File
@@ -16,7 +16,9 @@
.item-nav{
width:100%;
padding:10px 0;
border:0;
border-bottom:1px solid rgba(85, 166, 254, 0.2);
background:transparent;
font-family:'Noto Sans S Chinese', sans-serif;
font-weight:500;
color:rgba(255, 255, 255, 0.8);
@@ -44,7 +46,7 @@
.item-nav-content{
width: 155px;
height: 970px;
min-height: 970px;
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
@@ -54,6 +56,8 @@
left: 97px;
top: 0;
z-index: 1000;
padding: 14px 13px 18px 16px;
overflow-y:auto;
}
.item-nav-active{
@@ -81,4 +85,87 @@
height:45px;
background: url('../assets/icon/nav-line-logo.svg') no-repeat;
background-size: cover;
}
}
.sidebar-flyout-list{
display:flex;
flex-direction:column;
gap:2px;
position:relative;
}
.sidebar-flyout-list--level-1,
.sidebar-flyout-list--level-2,
.sidebar-flyout-list--level-3{
margin-left:19px;
padding-left:14px;
}
.sidebar-flyout-node{
position:relative;
}
.sidebar-flyout-node--has-children.sidebar-flyout-node--expanded > .sidebar-flyout-list::before{
content:'';
position:absolute;
left:0;
top:0;
width:1px;
height:100%;
background:rgba(43, 143, 254, 0.22);
}
.sidebar-flyout-item{
width:100%;
min-height:34px;
display:flex;
align-items:center;
gap:9px;
border:0;
background:transparent;
padding:3px 0;
font-family:Noto Sans S Chinese;
font-size:18px;
line-height:28px;
font-weight:500;
letter-spacing:0;
color:rgba(255, 255, 255, 0.8);
text-align:left;
cursor:pointer;
transition:color 0.2s ease, text-shadow 0.2s ease;
}
.sidebar-flyout-item:hover,
.sidebar-flyout-item--active{
color:#48ddff;
text-shadow:0 0 8px rgba(72, 221, 255, 0.25);
}
.sidebar-flyout-item--leaf{
justify-content:center;
padding-left:0;
}
.sidebar-flyout-list--level-1 > .sidebar-flyout-node > .sidebar-flyout-item--leaf,
.sidebar-flyout-list--level-2 > .sidebar-flyout-node > .sidebar-flyout-item--leaf,
.sidebar-flyout-list--level-3 > .sidebar-flyout-node > .sidebar-flyout-item--leaf{
padding-left:0;
}
.sidebar-flyout-arrow{
width:9px;
height:9px;
flex:0 0 9px;
background:url('../assets/icon/down-logo.svg') center / contain no-repeat;
transform:rotate(-90deg);
transition:transform 0.2s ease;
}
.sidebar-flyout-node--expanded > .sidebar-flyout-item > .sidebar-flyout-arrow{
transform:rotate(0deg);
}
.sidebar-flyout-label{
flex:1;
white-space:nowrap;
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { ElementType } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
@@ -285,7 +285,10 @@ export function PageSidebarDark({
const { theme } = useTheme();
const isDark = theme === "dark";
const isAdmin = getStoredAuth()?.system_role === "admin";
const visibleMenuItems = filterMenuItemsByRole(menuItems, !!isAdmin);
const visibleMenuItems = useMemo(
() => filterMenuItemsByRole(menuItems, !!isAdmin),
[isAdmin],
);
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
@@ -306,6 +309,26 @@ export function PageSidebarDark({
setSettingsOpen(true);
};
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
return true;
}
return item.children?.some(hasActiveChild) ?? false;
};
const initialActiveTopItem = visibleMenuItems.find(hasActiveChild) ?? visibleMenuItems[0];
const [activeTopId, setActiveTopId] = useState(initialActiveTopItem?.id ?? "qa-center");
useEffect(() => {
const matchedTopItem = visibleMenuItems.find(hasActiveChild);
if (matchedTopItem) {
setActiveTopId(matchedTopItem.id);
}
}, [pathname, visibleMenuItems]);
const activeTopItem = visibleMenuItems.find((item) => item.id === activeTopId) ?? initialActiveTopItem;
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
@@ -318,6 +341,51 @@ export function PageSidebarDark({
});
};
const handleTopItemClick = (item: MenuItem) => {
setActiveTopId(item.id);
if (!item.children?.length && item.path) {
navigate(item.path);
}
};
const handleMenuItemClick = (item: MenuItem) => {
if (item.children?.length) {
toggleExpanded(item.id);
return;
}
if (item.path) {
navigate(item.path);
}
};
const renderFlyoutMenu = (items: MenuItem[], level = 0) => (
<div className={`sidebar-flyout-list sidebar-flyout-list--level-${level}`}>
{items.map((item) => {
const hasChildren = !!item.children?.length;
const isExpanded = expandedIds.has(item.id);
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
return (
<div
key={item.id}
className={`sidebar-flyout-node sidebar-flyout-node--level-${level} ${hasChildren ? "sidebar-flyout-node--has-children" : ""} ${isExpanded ? "sidebar-flyout-node--expanded" : ""}`}
>
<button
type="button"
className={`sidebar-flyout-item ${isSelected ? "sidebar-flyout-item--active" : ""} ${hasChildren ? "sidebar-flyout-item--parent" : "sidebar-flyout-item--leaf"}`}
onClick={() => handleMenuItemClick(item)}
>
{hasChildren ? <span className="sidebar-flyout-arrow" aria-hidden="true" /> : null}
<span className="sidebar-flyout-label">{item.label}</span>
</button>
{hasChildren && isExpanded ? renderFlyoutMenu(item.children ?? [], level + 1) : null}
</div>
);
})}
</div>
);
return (
<>
<SettingsDialog
@@ -332,11 +400,18 @@ export function PageSidebarDark({
{/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
<div className="sidebar-contnet">
<div className="sidebar-contents">
<div className="item-nav-content"></div>
<div className="item-nav-content">
{activeTopItem?.children?.length ? renderFlyoutMenu(activeTopItem.children) : null}
</div>
{visibleMenuItems.map((item) => (
<div key={item.id} className={`item-nav ${item.id === 'qa-center' ? 'item-nav-active' : ''}`}>
<button
type="button"
key={item.id}
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""}`}
onClick={() => handleTopItemClick(item)}
>
<span className="item-nav__label">{item.label}</span>
</div>
</button>
))}
</div>
</div>
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2 } from 'lucide-react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SkillSearchStep } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition';
@@ -14,12 +14,16 @@ const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
list_skills: ListChecks,
select_skills: Sparkles,
invoke_skill: Zap,
skill_result: CheckCircle2,
stop_early: CircleStop,
};
const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
list_skills: '列出可用技能',
select_skills: '筛选相关技能',
invoke_skill: '调用技能',
skill_result: '检索结果',
stop_early: '提前停止',
};
export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
@@ -55,13 +59,33 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
{steps.map((step, idx) => {
const Icon = STEP_ICON[step.type] ?? Sparkles;
const label = STEP_LABEL[step.type] ?? step.type;
const isStopEarly = step.type === 'stop_early';
const isResult = step.type === 'skill_result';
return (
<li key={idx} className="flex items-start gap-2 text-[11px]">
<span className="shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300">
<span
className={cn(
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
isStopEarly
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: isResult
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
)}
>
{idx + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 font-medium text-violet-700 dark:text-violet-300">
<div
className={cn(
'flex items-center gap-1 font-medium',
isStopEarly
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
)}
>
<Icon className="size-3 shrink-0" />
<span>{label}</span>
{step.skillName && (
@@ -767,7 +767,9 @@ export function useAIWriting() {
// 乐观更新到下一阶段,跟老 hook 行为对齐
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
// re_search / enable_skill_search 都回到 researching;confirm 才进大纲
nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search')
? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
@@ -788,6 +790,7 @@ export function useAIWriting() {
};
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
@@ -17,9 +17,11 @@ export interface AIMaterial {
// 技能检索过程的单步记录(前端时间线展示"使用了 X 技能")
// list_skills:列出全部 enabled 技能
// select_skills:LLM 按相关度排序的候选
// invoke_skill:实际按顺序调用某个技能
// invoke_skill:开始调用某个技能去检索
// skill_result:某个技能检索返回了 N 条素材
// stop_early:累计素材达到上限,停止后续技能
export interface SkillSearchStep {
type: 'list_skills' | 'select_skills' | 'invoke_skill';
type: 'list_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
message: string;
skillNames?: string[];
skillName?: string;