Commit 8645a0f7 authored by edy's avatar edy

feat(ui): refine task workbench output panel

parent d60ecae9
Pipeline #18461 failed
...@@ -70,7 +70,7 @@ export function AppSidebar({ ...@@ -70,7 +70,7 @@ export function AppSidebar({
<nav className="nav-list" aria-label="主导航"> <nav className="nav-list" aria-label="主导航">
{[ {[
{ id: "chat" as const, label: "对话" }, { id: "chat" as const, label: "对话" },
{ id: "tasks" as const, label: "任务面板" }, { id: "tasks" as const, label: "工作台" },
{ id: "knowledge" as const, label: ui.knowledge }, { id: "knowledge" as const, label: ui.knowledge },
{ id: "plugins" as const, label: ui.plugins }, { id: "plugins" as const, label: ui.plugins },
{ id: "settings" as const, label: ui.settings } { id: "settings" as const, label: ui.settings }
......
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom" import type { TaskPanelArtifact, TaskPanelItem } from "@qjclaw/shared-types"
import type { TaskPanelArtifact, TaskPanelItem, TaskPanelStatus } from "@qjclaw/shared-types"
import { renderExpertIcon } from "../../components/icons/AppIcons" import { renderExpertIcon } from "../../components/icons/AppIcons"
import { Panel } from "../../components/ui/Panel" import { Panel } from "../../components/ui/Panel"
import { ScrollArea } from "../../components/ui/ScrollArea" import { ScrollArea } from "../../components/ui/ScrollArea"
import type { ExpertVisualKey } from "../shell/ExpertTree" import type { ExpertVisualKey } from "../shell/ExpertTree"
import { getDefaultTaskPanelDate, loadTaskPanelItems } from "./taskPanelData" import { getDefaultTaskPanelDate, loadTaskPanelItems, summarizeTaskPanelItems } from "./taskPanelData"
const statusLabels: Record<TaskPanelStatus, string> = {
pending: "待处理",
running: "执行中",
completed: "已完成",
failed: "失败"
}
function resolveTaskExpertIconKey(expertName: string): ExpertVisualKey { function resolveTaskExpertIconKey(expertName: string): ExpertVisualKey {
const seed = expertName.toLowerCase() const seed = expertName.toLowerCase()
...@@ -55,70 +47,109 @@ function resolveTaskExpertIconKey(expertName: string): ExpertVisualKey { ...@@ -55,70 +47,109 @@ function resolveTaskExpertIconKey(expertName: string): ExpertVisualKey {
return "general" return "general"
} }
function TaskStatus({ item }: { item: TaskPanelItem }) { interface TaskPanelOutputItem {
artifact: TaskPanelArtifact
task: TaskPanelItem
}
function TaskPanelMetricIcon({ kind }: { kind: "credits" | "messages" | "artifacts" | "employees" }) {
if (kind === "credits") {
return ( return (
<div className={"task-panel-status task-panel-status-" + item.status}> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<span className="task-panel-status-icon" title={item.statusDetail} aria-hidden="true"> <path d="M12 3.75 14.3 8.4l5.1.74-3.7 3.6.87 5.08L12 15.42l-4.57 2.4.87-5.08-3.7-3.6 5.1-.74L12 3.75Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
{item.status === "completed" ? <span></span> : null} </svg>
{item.status === "running" ? ( )
<span className="task-panel-running-dots" aria-hidden="true"> }
<span /> if (kind === "messages") {
<span /> return (
<span /> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
</span> <path d="M5.25 6.75A2.75 2.75 0 0 1 8 4h8a2.75 2.75 0 0 1 2.75 2.75v5.5A2.75 2.75 0 0 1 16 15h-3.3l-3.25 3.05c-.44.41-1.15.1-1.15-.51V15H8a2.75 2.75 0 0 1-2.75-2.75v-5.5Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
) : null} <path d="M8.75 8.4h6.5M8.75 11.25h4.5" fill="none" stroke="currentColor" strokeLinecap="round" strokeWidth="1.6" />
{item.status === "pending" ? <span></span> : null} </svg>
{item.status === "failed" ? <span>!</span> : null} )
</span> }
<strong className="task-panel-status-text" title={item.statusDetail}>{statusLabels[item.status]}</strong> if (kind === "artifacts") {
</div> return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M7 4.25h6.35L18 8.9v8.85A2.25 2.25 0 0 1 15.75 20H7a2.25 2.25 0 0 1-2.25-2.25V6.5A2.25 2.25 0 0 1 7 4.25Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
<path d="M13.25 4.5v3.35c0 .7.57 1.27 1.27 1.27h3.25M8.3 12.2h6.6M8.3 15.45h4.4" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.55" />
</svg>
)
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M8.75 11.25a3.25 3.25 0 1 1 0-6.5 3.25 3.25 0 0 1 0 6.5Zm6.75.5a2.75 2.75 0 1 1 0-5.5 2.75 2.75 0 0 1 0 5.5Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.65" />
<path d="M3.9 18.75a4.85 4.85 0 0 1 9.7 0m-.55-1.3a4.05 4.05 0 0 1 7.05 1.3" fill="none" stroke="currentColor" strokeLinecap="round" strokeWidth="1.65" />
</svg>
) )
} }
function TaskArtifactList({ function TaskPanelOutputIcon({ artifact }: { artifact: TaskPanelArtifact }) {
artifacts, const normalizedKind = [artifact.kind, artifact.name, artifact.url].filter(Boolean).join(" ").toLowerCase()
copiedArtifactId, if (/视频|video|mp4|mov|m4v|avi|webm/.test(normalizedKind)) {
onCopy
}: {
artifacts: TaskPanelArtifact[]
copiedArtifactId: string
onCopy: (artifactId: string, artifactUrl: string) => void
}) {
return ( return (
<ul className="task-panel-artifact-list" aria-label="产物清单"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
{artifacts.map((artifact) => ( <path d="M5.25 7.25A2.25 2.25 0 0 1 7.5 5h6.25A2.25 2.25 0 0 1 16 7.25v.55l2.55-1.45c.74-.42 1.65.12 1.65.97v9.36c0 .85-.91 1.39-1.65.97L16 16.2v.55A2.25 2.25 0 0 1 13.75 19H7.5a2.25 2.25 0 0 1-2.25-2.25v-9.5Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
<li <path d="m9.6 9.1 3.7 2.4-3.7 2.4V9.1Z" fill="currentColor" />
key={artifact.id} </svg>
className={copiedArtifactId === artifact.id ? "task-panel-artifact-item-copied" : undefined} )
title={artifact.url ?? artifact.name} }
> if (/表格|xlsx|csv|sheet/.test(normalizedKind)) {
<span className="task-panel-artifact-name" title={artifact.name}>{artifact.name}</span> return (
{artifact.url ? ( <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<button <path d="M5.5 5.75A2.25 2.25 0 0 1 7.75 3.5h8.5a2.25 2.25 0 0 1 2.25 2.25v12.5a2.25 2.25 0 0 1-2.25 2.25h-8.5a2.25 2.25 0 0 1-2.25-2.25V5.75Z" fill="none" stroke="currentColor" strokeWidth="1.7" />
type="button" <path d="M5.9 9.25h12.2M5.9 13h12.2M10 9.25v10.8" fill="none" stroke="currentColor" strokeLinecap="round" strokeWidth="1.45" />
className="task-panel-artifact-url" </svg>
title={artifact.url} )
onClick={() => onCopy(artifact.id, artifact.url ?? "")} }
> return (
{artifact.url} <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
</button> <path d="M7.25 3.75h6.1L18.75 9v9.25a2 2 0 0 1-2 2h-9.5a2 2 0 0 1-2-2V5.75a2 2 0 0 1 2-2Z" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
) : null} <path d="M13.2 4.1v3.7c0 .7.56 1.26 1.26 1.26h3.9M8.4 12.5h7.2M8.4 15.8h4.75" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" />
{copiedArtifactId === artifact.id ? ( </svg>
<span className="task-panel-artifact-copied" aria-live="polite">已复制</span> )
) : null} }
</li>
function TaskPanelStatCards({ items }: { items: TaskPanelItem[] }) {
const summary = useMemo(() => summarizeTaskPanelItems(items), [items])
const stats = [
{ key: "credits", label: "今日消耗Credits", value: summary.creditsUsed.toLocaleString("zh-CN"), icon: "credits" as const },
{ key: "messages", label: "今日消息条数", value: summary.messageCount.toLocaleString("zh-CN"), icon: "messages" as const },
{ key: "artifacts", label: "今日产物数量", value: summary.artifactCount.toLocaleString("zh-CN"), icon: "artifacts" as const },
{ key: "employees", label: "参与员工数量", value: summary.employeeCount.toLocaleString("zh-CN"), icon: "employees" as const }
]
return (
<section className="task-panel-stats" aria-label="任务统计">
{stats.map((stat) => (
<article key={stat.key} className={"task-panel-stat-card task-panel-stat-card-" + stat.key}>
<span className="task-panel-stat-icon" aria-hidden="true">
<TaskPanelMetricIcon kind={stat.icon} />
</span>
<strong className="task-panel-stat-value">{stat.value}</strong>
<span className="task-panel-stat-label">{stat.label}</span>
</article>
))} ))}
</ul> </section>
) )
} }
function TaskArtifacts({ item }: { item: TaskPanelItem }) { function formatTaskPanelOutputTime(task: TaskPanelItem) {
const dateText = task.date.replaceAll("-", "/")
const timeText = task.updatedAt?.trim() ?? "00:00:00"
const timeMatch = timeText.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
if (!timeMatch) {
return `${dateText} ${timeText}`
}
const hour = timeMatch[1].padStart(2, "0")
const minute = timeMatch[2]
const second = timeMatch[3] ?? "00"
return `${dateText} ${hour}:${minute}:${second}`
}
function TaskPanelOutputList({ outputs }: { outputs: TaskPanelOutputItem[] }) {
const [copiedArtifactId, setCopiedArtifactId] = useState("") const [copiedArtifactId, setCopiedArtifactId] = useState("")
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [popoverPosition, setPopoverPosition] = useState<{ top: number; left: number; width: number; maxHeight: number } | null>(null)
const menuRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const popoverRef = useRef<HTMLDivElement | null>(null)
const copiedTimerRef = useRef<number | null>(null) const copiedTimerRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
...@@ -129,78 +160,9 @@ function TaskArtifacts({ item }: { item: TaskPanelItem }) { ...@@ -129,78 +160,9 @@ function TaskArtifacts({ item }: { item: TaskPanelItem }) {
} }
}, []) }, [])
const updatePopoverPosition = useCallback(() => {
const triggerElement = triggerRef.current
if (!triggerElement) {
return
}
const margin = 16
const gap = 8
const triggerRect = triggerElement.getBoundingClientRect()
const width = Math.min(520, Math.max(280, window.innerWidth - margin * 2))
const left = Math.min(Math.max(triggerRect.left, margin), window.innerWidth - width - margin)
const belowTop = triggerRect.bottom + gap
const spaceBelow = window.innerHeight - belowTop - margin
const spaceAbove = triggerRect.top - margin - gap
const preferBelow = spaceBelow >= 160 || spaceBelow >= spaceAbove
const maxHeight = Math.min(220, Math.max(140, preferBelow ? spaceBelow : spaceAbove))
const top = preferBelow ? belowTop : Math.max(margin, triggerRect.top - gap - maxHeight)
setPopoverPosition({ top, left, width, maxHeight })
}, [])
useEffect(() => { useEffect(() => {
setIsMenuOpen(false)
setCopiedArtifactId("") setCopiedArtifactId("")
}, [item.id]) }, [outputs])
useEffect(() => {
if (!isMenuOpen) {
return
}
const handlePointerDown = (event: PointerEvent) => {
const menuElement = menuRef.current
const popoverElement = popoverRef.current
if (
event.target instanceof Node
&& !menuElement?.contains(event.target)
&& !popoverElement?.contains(event.target)
) {
setIsMenuOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsMenuOpen(false)
}
}
document.addEventListener("pointerdown", handlePointerDown, true)
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true)
document.removeEventListener("keydown", handleKeyDown)
}
}, [isMenuOpen])
useEffect(() => {
if (!isMenuOpen) {
return
}
updatePopoverPosition()
window.addEventListener("resize", updatePopoverPosition)
window.addEventListener("scroll", updatePopoverPosition, true)
return () => {
window.removeEventListener("resize", updatePopoverPosition)
window.removeEventListener("scroll", updatePopoverPosition, true)
}
}, [isMenuOpen, updatePopoverPosition])
const copyArtifactUrl = async (artifactId: string, artifactUrl: string) => { const copyArtifactUrl = async (artifactId: string, artifactUrl: string) => {
try { try {
...@@ -218,65 +180,61 @@ function TaskArtifacts({ item }: { item: TaskPanelItem }) { ...@@ -218,65 +180,61 @@ function TaskArtifacts({ item }: { item: TaskPanelItem }) {
} }
} }
if (!item.artifacts.length) {
return ( return (
<div className="task-panel-artifact-center"> <section className="task-panel-output-panel" aria-label="内容产出列表">
<span className="task-panel-muted">暂无产物</span> <div className="task-panel-output-header">
<div className="task-panel-output-heading">
<span className="task-panel-output-heading-icon" aria-hidden="true">
📦
</span>
<h2>内容产出</h2>
</div> </div>
)
}
if (item.artifacts.length <= 2) {
return (
<div className="task-panel-artifact-center">
<TaskArtifactList
artifacts={item.artifacts}
copiedArtifactId={copiedArtifactId}
onCopy={(artifactId, artifactUrl) => void copyArtifactUrl(artifactId, artifactUrl)}
/>
</div> </div>
) <div className="task-panel-output-list">
} {outputs.length ? outputs.map(({ artifact, task }) => (
<article key={task.id + "-" + artifact.id} className="task-panel-output-item">
return ( <span className="task-panel-output-icon" aria-hidden="true">
<div className="task-panel-artifact-menu" ref={menuRef}> <TaskPanelOutputIcon artifact={artifact} />
</span>
<div className="task-panel-output-main">
<div className="task-panel-output-title-row">
<strong title={artifact.name}>{artifact.name}</strong>
<span className="task-panel-output-kind">{artifact.kind ?? "产物"}</span>
</div>
<p>{artifact.summary ?? task.taskTitle}</p>
{artifact.url ? (
<div className="task-panel-output-url-row">
<button <button
ref={triggerRef}
type="button" type="button"
className="task-panel-artifact-trigger" className="task-panel-output-url"
aria-haspopup="menu" title={artifact.url}
aria-expanded={isMenuOpen} onClick={() => void copyArtifactUrl(artifact.id, artifact.url ?? "")}
aria-controls={item.id + "-artifact-menu"}
onClick={() => {
if (!isMenuOpen) {
updatePopoverPosition()
}
setIsMenuOpen((current) => !current)
}}
> >
{item.artifacts.length} 个产物 {artifact.url}
</button> </button>
{isMenuOpen && popoverPosition ? createPortal(( {copiedArtifactId === artifact.id ? (
<div <span className="task-panel-artifact-copied" aria-live="polite">✅已复制</span>
ref={popoverRef} ) : null}
className="task-panel-artifact-popover"
id={item.id + "-artifact-menu"}
role="menu"
style={{
top: popoverPosition.top,
left: popoverPosition.left,
width: popoverPosition.width,
maxHeight: popoverPosition.maxHeight
}}
>
<TaskArtifactList
artifacts={item.artifacts}
copiedArtifactId={copiedArtifactId}
onCopy={(artifactId, artifactUrl) => void copyArtifactUrl(artifactId, artifactUrl)}
/>
</div> </div>
), document.body) : null} ) : null}
</div> </div>
<div className="task-panel-output-meta">
<span className={"task-panel-expert-icon task-panel-expert-icon-" + resolveTaskExpertIconKey(task.expertName)} aria-hidden="true">
{renderExpertIcon(resolveTaskExpertIconKey(task.expertName))}
</span>
<div>
<strong title={task.expertName}>{task.expertName}</strong>
<span title={task.taskTitle}>{formatTaskPanelOutputTime(task)} · {task.taskTitle}</span>
</div>
</div>
</article>
)) : (
<div className="empty-state task-panel-state task-panel-output-empty">
当前日期暂无内容产出
</div>
)}
</div>
</section>
) )
} }
...@@ -299,7 +257,6 @@ function TaskPanelLoadingState() { ...@@ -299,7 +257,6 @@ function TaskPanelLoadingState() {
export function TaskPanelView() { export function TaskPanelView() {
const [selectedDate, setSelectedDate] = useState(getDefaultTaskPanelDate) const [selectedDate, setSelectedDate] = useState(getDefaultTaskPanelDate)
const [items, setItems] = useState<TaskPanelItem[]>([]) const [items, setItems] = useState<TaskPanelItem[]>([])
const [selectedTaskIds, setSelectedTaskIds] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [errorText, setErrorText] = useState("") const [errorText, setErrorText] = useState("")
const [greetingText, setGreetingText] = useState("") const [greetingText, setGreetingText] = useState("")
...@@ -353,22 +310,9 @@ export function TaskPanelView() { ...@@ -353,22 +310,9 @@ export function TaskPanelView() {
const displayDate = useMemo(() => selectedDate.replaceAll("-", "/"), [selectedDate]) const displayDate = useMemo(() => selectedDate.replaceAll("-", "/"), [selectedDate])
const displayMonth = useMemo(() => `${Number(selectedDate.slice(5, 7))}月`, [selectedDate]) const displayMonth = useMemo(() => `${Number(selectedDate.slice(5, 7))}月`, [selectedDate])
const expertRows = useMemo(() => { const outputItems = useMemo<TaskPanelOutputItem[]>(() => {
const groups = new Map<string, TaskPanelItem[]>() return items.flatMap((task) => task.artifacts.map((artifact) => ({ artifact, task })))
for (const item of items) { }, [items])
const groupItems = groups.get(item.expertName)
if (groupItems) {
groupItems.push(item)
} else {
groups.set(item.expertName, [item])
}
}
return Array.from(groups, ([expertName, tasks]) => {
const selectedTask = tasks.find((task) => task.id === selectedTaskIds[expertName]) ?? tasks[0]
return { expertName, tasks, selectedTask }
})
}, [items, selectedTaskIds])
return ( return (
<div className="page-stack task-panel-page-stack"> <div className="page-stack task-panel-page-stack">
...@@ -417,47 +361,9 @@ export function TaskPanelView() { ...@@ -417,47 +361,9 @@ export function TaskPanelView() {
{!loading && errorText ? <div className="notice error task-panel-state" role="alert">{errorText}</div> : null} {!loading && errorText ? <div className="notice error task-panel-state" role="alert">{errorText}</div> : null}
{!loading && !errorText && !items.length ? <div className="empty-state task-panel-state">当天暂无任务</div> : null} {!loading && !errorText && !items.length ? <div className="empty-state task-panel-state">当天暂无任务</div> : null}
{!loading && !errorText && items.length ? ( {!loading && !errorText && items.length ? (
<div className="task-panel-table" role="table" aria-label="任务面板"> <div className="task-panel-content">
<div className="task-panel-row task-panel-row-head" role="row"> <TaskPanelStatCards items={items} />
<div role="columnheader">数字员工</div> <TaskPanelOutputList outputs={outputItems} />
<div role="columnheader">任务列表</div>
<div role="columnheader">执行状态</div>
<div role="columnheader">产物清单</div>
</div>
{expertRows.map((row) => (
<article key={row.expertName} className="task-panel-row" role="row">
<div className="task-panel-expert-cell" role="cell">
<span className={"task-panel-expert-icon task-panel-expert-icon-" + resolveTaskExpertIconKey(row.expertName)} aria-hidden="true">
{renderExpertIcon(resolveTaskExpertIconKey(row.expertName))}
</span>
<strong title={row.expertName}>{row.expertName}</strong>
</div>
<div className="task-panel-task-cell" role="cell">
<select
aria-label={row.expertName + "任务列表"}
title={row.selectedTask.taskTitle}
value={row.selectedTask.id}
onChange={(event) => {
const selectedTaskId = event.currentTarget.value
setSelectedTaskIds((current) => ({
...current,
[row.expertName]: selectedTaskId
}))
}}
>
{row.tasks.map((task) => (
<option key={task.id} title={task.taskTitle} value={task.id}>{task.taskTitle}</option>
))}
</select>
</div>
<div role="cell">
<TaskStatus item={row.selectedTask} />
</div>
<div role="cell">
<TaskArtifacts item={row.selectedTask} />
</div>
</article>
))}
</div> </div>
) : null} ) : null}
</ScrollArea> </ScrollArea>
......
import type { TaskPanelItem } from "@qjclaw/shared-types" import type { TaskPanelItem } from "@qjclaw/shared-types"
export interface TaskPanelSummary {
creditsUsed: number
messageCount: number
artifactCount: number
employeeCount: number
}
function toDateInputValue(date: Date) { function toDateInputValue(date: Date) {
const year = date.getFullYear() const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0") const month = String(date.getMonth() + 1).padStart(2, "0")
...@@ -25,12 +32,15 @@ export const mockTaskPanelItems: TaskPanelItem[] = [ ...@@ -25,12 +32,15 @@ export const mockTaskPanelItems: TaskPanelItem[] = [
taskTitle: "整理本周选题方向与发布节奏", taskTitle: "整理本周选题方向与发布节奏",
status: "running", status: "running",
statusDetail: "正在汇总账号定位、目标人群和栏目节奏", statusDetail: "正在汇总账号定位、目标人群和栏目节奏",
creditsUsed: 1280,
messageCount: 36,
updatedAt: "10:42",
artifacts: [ artifacts: [
{ id: "artifact-content-outline", name: "选题规划草稿.md", kind: "文档", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/选题规划草稿.md" }, { id: "artifact-content-outline", name: "选题规划草稿.md", kind: "文档", summary: "本周内容主线、栏目节奏与素材需求草案。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/选题规划草稿.md" },
{ id: "artifact-content-calendar", name: "发布日历.xlsx", kind: "表格", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/发布日历.xlsx" }, { id: "artifact-content-calendar", name: "发布日历.xlsx", kind: "表格", summary: "按平台拆分的发布排期与负责人视图。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/发布日历.xlsx" },
{ id: "artifact-content-persona", name: "目标人群画像.md", kind: "文档", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/目标人群画像.md" }, { id: "artifact-content-persona", name: "目标人群画像.md", kind: "文档", summary: "核心受众痛点、决策因素与内容偏好。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/目标人群画像.md" },
{ id: "artifact-content-topics", name: "栏目选题池.csv", kind: "表格", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/栏目选题池.csv" }, { id: "artifact-content-topics", name: "栏目选题池.csv", kind: "表格", summary: "可复用选题、关键词和参考链接集合。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/栏目选题池.csv" },
{ id: "artifact-content-brief", name: "账号定位简报.pdf", kind: "文档", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/账号定位简报.pdf" } { id: "artifact-content-brief", name: "账号定位简报.pdf", kind: "文档", summary: "账号定位、差异化表达和近期目标摘要。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/账号定位简报.pdf" }
] ]
}, },
{ {
...@@ -40,8 +50,11 @@ export const mockTaskPanelItems: TaskPanelItem[] = [ ...@@ -40,8 +50,11 @@ export const mockTaskPanelItems: TaskPanelItem[] = [
taskTitle: "复盘昨日内容表现", taskTitle: "复盘昨日内容表现",
status: "completed", status: "completed",
statusDetail: "已完成互动数据摘要与优化建议", statusDetail: "已完成互动数据摘要与优化建议",
creditsUsed: 640,
messageCount: 18,
updatedAt: "09:18",
artifacts: [ artifacts: [
{ id: "artifact-content-review", name: "昨日内容复盘与下轮优化建议.md", kind: "文档", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/昨日内容复盘与下轮优化建议.md" } { id: "artifact-content-review", name: "昨日内容复盘与下轮优化建议.md", kind: "文档", summary: "互动、转化和评论反馈的复盘结论。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/昨日内容复盘与下轮优化建议.md" }
] ]
}, },
{ {
...@@ -51,8 +64,11 @@ export const mockTaskPanelItems: TaskPanelItem[] = [ ...@@ -51,8 +64,11 @@ export const mockTaskPanelItems: TaskPanelItem[] = [
taskTitle: "生成知乎回答结构", taskTitle: "生成知乎回答结构",
status: "completed", status: "completed",
statusDetail: "已完成回答大纲与首版正文", statusDetail: "已完成回答大纲与首版正文",
creditsUsed: 920,
messageCount: 24,
updatedAt: "11:05",
artifacts: [ artifacts: [
{ id: "artifact-zhihu-answer", name: "知乎回答初稿.md", kind: "文档", url: "/Users/edy/Documents/qianjiangclaw/tasks/zhihu/知乎回答初稿.md" } { id: "artifact-zhihu-answer", name: "知乎回答初稿.md", kind: "文档", summary: "问题拆解、回答结构和首版正文内容。", url: "/Users/edy/Documents/qianjiangclaw/tasks/zhihu/知乎回答初稿.md" }
] ]
}, },
{ {
...@@ -62,8 +78,11 @@ export const mockTaskPanelItems: TaskPanelItem[] = [ ...@@ -62,8 +78,11 @@ export const mockTaskPanelItems: TaskPanelItem[] = [
taskTitle: "整理竞品问答素材", taskTitle: "整理竞品问答素材",
status: "running", status: "running",
statusDetail: "正在提取高赞回答结构和关键词", statusDetail: "正在提取高赞回答结构和关键词",
creditsUsed: 760,
messageCount: 21,
updatedAt: "11:36",
artifacts: [ artifacts: [
{ id: "artifact-zhihu-research", name: "竞品问答素材汇总-长文件名用于验证省略显示效果.xlsx", kind: "表格", url: "/Users/edy/Documents/qianjiangclaw/tasks/zhihu/竞品问答素材汇总-长文件名用于验证省略显示效果.xlsx" } { id: "artifact-zhihu-research", name: "竞品问答素材汇总-长文件名用于验证省略显示效果.xlsx", kind: "表格", summary: "高赞回答结构、关键词和引用素材汇总。", url: "/Users/edy/Documents/qianjiangclaw/tasks/zhihu/竞品问答素材汇总-长文件名用于验证省略显示效果.xlsx" }
] ]
}, },
{ {
...@@ -73,6 +92,9 @@ export const mockTaskPanelItems: TaskPanelItem[] = [ ...@@ -73,6 +92,9 @@ export const mockTaskPanelItems: TaskPanelItem[] = [
taskTitle: "筛选高意向线索名单", taskTitle: "筛选高意向线索名单",
status: "pending", status: "pending",
statusDetail: "等待线索表上传后开始处理", statusDetail: "等待线索表上传后开始处理",
creditsUsed: 0,
messageCount: 4,
updatedAt: "12:10",
artifacts: [] artifacts: []
}, },
{ {
...@@ -82,12 +104,47 @@ export const mockTaskPanelItems: TaskPanelItem[] = [ ...@@ -82,12 +104,47 @@ export const mockTaskPanelItems: TaskPanelItem[] = [
taskTitle: "生成活动海报文案", taskTitle: "生成活动海报文案",
status: "failed", status: "failed",
statusDetail: "素材包缺少主视觉图片", statusDetail: "素材包缺少主视觉图片",
creditsUsed: 360,
messageCount: 12,
updatedAt: "18:22",
artifacts: [
{ id: "artifact-poster-brief", name: "活动海报文案草稿.txt", kind: "文档", summary: "活动主题、主标题和利益点文案草稿。", url: "/Users/edy/Documents/qianjiangclaw/tasks/poster/活动海报文案草稿.txt" }
]
},
{
id: "mock-task-yesterday-leads",
date: toDateInputValue(addDays(new Date(), -1)),
expertName: "平台精准线索专家",
taskTitle: "清洗昨日线索表",
status: "completed",
statusDetail: "已完成重复线索剔除和等级标注",
creditsUsed: 520,
messageCount: 16,
updatedAt: "17:40",
artifacts: [ artifacts: [
{ id: "artifact-poster-brief", name: "活动海报文案草稿.txt", kind: "文档", url: "/Users/edy/Documents/qianjiangclaw/tasks/poster/活动海报文案草稿.txt" } { id: "artifact-yesterday-leads", name: "昨日高意向线索清单.xlsx", kind: "表格", summary: "线索分级、跟进优先级和备注字段。", url: "/Users/edy/Documents/qianjiangclaw/tasks/leads/昨日高意向线索清单.xlsx" },
{ id: "artifact-yesterday-followup", name: "跟进话术建议.md", kind: "文档", summary: "按线索来源拆分的首轮沟通建议。", url: "/Users/edy/Documents/qianjiangclaw/tasks/leads/跟进话术建议.md" }
] ]
} }
] ]
export function summarizeTaskPanelItems(items: TaskPanelItem[]): TaskPanelSummary {
const employeeCount = new Set(items.map((item) => item.expertName)).size
const summary = items.reduce<TaskPanelSummary>((nextSummary, item) => {
nextSummary.creditsUsed += item.creditsUsed ?? 0
nextSummary.messageCount += item.messageCount ?? 0
nextSummary.artifactCount += item.artifacts.length
return nextSummary
}, {
creditsUsed: 0,
messageCount: 0,
artifactCount: 0,
employeeCount
})
return summary
}
export async function loadTaskPanelItems(date: string): Promise<TaskPanelItem[]> { export async function loadTaskPanelItems(date: string): Promise<TaskPanelItem[]> {
return mockTaskPanelItems.filter((item) => item.date === date) return mockTaskPanelItems.filter((item) => item.date === date)
} }
...@@ -170,6 +170,12 @@ ...@@ -170,6 +170,12 @@
padding-right: 2px; padding-right: 2px;
} }
.task-panel-scroll .scroll-area-content {
display: grid;
min-height: 0;
overflow: hidden;
}
.task-panel-state { .task-panel-state {
display: grid; display: grid;
min-height: 220px; min-height: 220px;
...@@ -183,62 +189,89 @@ ...@@ -183,62 +189,89 @@
border-color: rgba(203, 213, 225, 0.82); border-color: rgba(203, 213, 225, 0.82);
} }
.task-panel-table { .task-panel-content {
display: grid; display: grid;
gap: 6px; grid-template-rows: auto minmax(0, 1fr);
min-width: 860px; gap: 12px;
height: 100%;
min-width: 0;
min-height: 0;
} }
.task-panel-row { .task-panel-stats {
display: grid; display: grid;
grid-template-columns: 150px 150px 138px minmax(280px, 1fr); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px; gap: 10px;
align-items: center; min-width: 0;
min-height: 82px; }
padding: 12px 14px;
overflow: visible; .task-panel-stat-card {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
grid-template-rows: auto auto;
column-gap: 10px;
row-gap: 3px;
min-width: 0;
min-height: 92px;
padding: 15px;
border-radius: 14px;
border: 1px solid rgba(219, 234, 254, 0.92);
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.045);
}
.task-panel-stat-icon {
display: grid;
grid-row: 1 / span 2;
width: 38px;
height: 38px;
place-items: center;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(226, 232, 240, 0.96); color: #2563eb;
background: rgba(255, 255, 255, 0.88); background: #eff6ff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.035); border: 1px solid #bfdbfe;
transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, transform 160ms ease;
} }
.task-panel-row-head { .task-panel-stat-icon svg {
min-height: 36px; width: 21px;
align-items: center; height: 21px;
padding: 8px 14px;
overflow: hidden;
border-radius: 10px;
background: rgba(248, 250, 252, 0.92);
color: #64748b;
font-size: 12px;
font-weight: 700;
box-shadow: none;
} }
.task-panel-row:not(.task-panel-row-head):hover, .task-panel-stat-card-messages .task-panel-stat-icon,
.task-panel-row:not(.task-panel-row-head):focus-within { .task-panel-stat-card-employees .task-panel-stat-icon {
border-color: rgba(147, 197, 253, 0.86); color: #0f766e;
background: #ffffff; background: #ecfdf5;
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.07); border-color: #a7f3d0;
transform: translateY(-1px);
} }
.task-panel-row > div { .task-panel-stat-card-artifacts .task-panel-stat-icon {
min-width: 0; color: #0891b2;
background: #ecfeff;
border-color: #a5f3fc;
} }
.task-panel-row:not(.task-panel-row-head) > div:nth-child(3) { .task-panel-stat-label {
padding-left: 14px; align-self: start;
min-width: 0;
color: #64748b;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.task-panel-row:not(.task-panel-row-head) > div:nth-child(4) { .task-panel-stat-value {
display: flex; align-self: end;
position: relative; min-width: 0;
align-items: center; color: #17253d;
justify-content: flex-start; font-size: 24px;
padding-left: 0; font-weight: 780;
line-height: 1.1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.task-panel-expert-cell { .task-panel-expert-cell {
...@@ -300,41 +333,6 @@ ...@@ -300,41 +333,6 @@
white-space: nowrap; white-space: nowrap;
} }
.task-panel-task-cell {
display: flex;
min-width: 0;
}
.task-panel-task-cell select {
width: 10em;
max-width: 100%;
min-width: 0;
min-height: 34px;
padding: 0 34px 0 12px;
border-radius: 10px;
border: 1px solid #d8e1ef;
background: #f8fafc;
color: #1f2f49;
font: inherit;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.task-panel-task-cell select:hover,
.task-panel-task-cell select:focus-visible {
border-color: #93c5fd;
background: #ffffff;
}
.task-panel-task-cell select:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.26);
outline-offset: 2px;
}
.task-panel-status { .task-panel-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
...@@ -420,165 +418,254 @@ ...@@ -420,165 +418,254 @@
border: 1px solid #fecaca; border: 1px solid #fecaca;
} }
.task-panel-artifact-list { .task-panel-output-panel {
display: grid; display: grid;
gap: 5px; grid-template-rows: auto minmax(0, 1fr);
width: 100%; gap: 10px;
margin: 0;
padding: 0;
list-style: none;
min-width: 0;
overflow: visible;
}
.task-panel-artifact-center {
display: flex;
width: 100%;
min-width: 0; min-width: 0;
align-items: center; min-height: 0;
justify-content: flex-start;
} }
.task-panel-artifact-menu { .task-panel-output-header {
position: relative; display: grid;
display: flex; padding: 14px 16px;
width: 100%; border-radius: 14px;
min-width: 0; border: 1px solid rgba(219, 234, 254, 0.82);
align-items: center; background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
justify-content: flex-start;
z-index: 4;
} }
.task-panel-artifact-trigger { .task-panel-output-heading {
display: inline-flex; display: inline-flex;
min-height: 32px;
align-items: center; align-items: center;
justify-content: center; gap: 12px;
padding: 0 12px; min-width: 0;
border-radius: 10px;
border: 1px solid #cfe0f5;
background: #ffffff;
color: #1f2f49;
box-shadow: none;
font: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
cursor: pointer;
} }
.task-panel-artifact-trigger:hover, .task-panel-output-heading-icon {
.task-panel-artifact-trigger:focus-visible, display: grid;
.task-panel-artifact-trigger[aria-expanded="true"] { width: 42px;
border-color: #93c5fd; height: 42px;
color: #1d4ed8; flex: 0 0 auto;
place-items: center;
border-radius: 13px;
color: #0f766e;
background: linear-gradient(135deg, #ecfeff 0%, #ecfdf5 100%);
border: 1px solid #99f6e4;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 8px 16px rgba(15, 118, 110, 0.1);
font-size: 24px;
line-height: 1;
} }
.task-panel-artifact-trigger:focus-visible { .task-panel-output-header h2 {
outline: 2px solid rgba(37, 99, 235, 0.28); margin: 0;
outline-offset: 2px; color: #17253d;
font-size: 16px;
font-weight: 780;
line-height: 1.35;
} }
.task-panel-artifact-popover { .task-panel-output-list {
position: fixed; display: grid;
align-content: start;
gap: 8px;
min-width: 0;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 6px; overscroll-behavior: contain;
border-radius: 12px; padding-right: 4px;
border: 1px solid rgba(203, 213, 225, 0.96);
background: #ffffff;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.14);
transform: none;
z-index: 1200;
}
.task-panel-artifact-popover .task-panel-artifact-list {
width: 100%;
} }
.task-panel-artifact-list li { .task-panel-output-item {
display: grid; display: grid;
position: relative; position: relative;
grid-template-columns: minmax(96px, 0.72fr) minmax(0, 1.28fr) auto; grid-template-columns: 30px minmax(0, 1fr) minmax(260px, 0.46fr);
gap: 12px;
align-items: center; align-items: center;
gap: 8px;
min-width: 0; min-width: 0;
min-height: 32px; padding: 14px;
padding: 5px 8px; border-radius: 14px;
border-radius: 9px; border: 1px solid rgba(226, 232, 240, 0.96);
background: rgba(248, 250, 252, 0.88); background: rgba(255, 255, 255, 0.92);
border: 1px solid transparent; box-shadow: 0 8px 18px rgba(15, 23, 42, 0.035);
transition: border-color 160ms ease, background 160ms ease; transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, transform 160ms ease;
} }
.task-panel-artifact-list li:hover, .task-panel-output-item:hover,
.task-panel-artifact-list li:focus-within { .task-panel-output-item:focus-within {
border-color: #dbeafe; border-color: rgba(147, 197, 253, 0.86);
background: #ffffff; background: #ffffff;
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.07);
transform: translateY(-1px);
}
.task-panel-output-icon {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 10px;
color: #0f766e;
background: #ecfdf5;
border: 1px solid #a7f3d0;
} }
.task-panel-artifact-list li.task-panel-artifact-item-copied { .task-panel-output-icon svg {
padding-right: 8px; width: 18px;
height: 18px;
} }
.task-panel-artifact-name, .task-panel-output-main,
.task-panel-artifact-url { .task-panel-output-meta,
.task-panel-output-meta > div {
min-width: 0; min-width: 0;
font-size: 12.5px; }
line-height: 1.5;
.task-panel-output-main {
display: grid;
gap: 5px;
}
.task-panel-output-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.task-panel-output-title-row strong {
min-width: 0;
color: #17253d;
font-size: 14px;
font-weight: 760;
line-height: 1.35;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.task-panel-artifact-name { .task-panel-output-kind {
color: #1f2f49; flex: 0 0 auto;
padding: 3px 8px;
border-radius: 999px;
color: #0f766e;
background: #ccfbf1;
font-size: 12px;
font-weight: 750; font-weight: 750;
line-height: 1;
white-space: nowrap;
} }
.task-panel-artifact-url { .task-panel-output-main p {
color: #60728c; margin: 0;
color: #52657f;
font-size: 12.5px;
font-weight: 600;
line-height: 1.5;
}
.task-panel-output-url-row {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
justify-self: start;
max-width: 100%;
}
.task-panel-output-url {
flex: 1 1 auto;
min-width: 0;
min-height: 24px; min-height: 24px;
padding: 0; padding: 0;
border: 0; border: 0;
border-radius: 6px;
background: transparent; background: transparent;
color: #60728c;
font: inherit; font: inherit;
font-size: 12.5px;
font-weight: 600;
line-height: 1.45;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.task-panel-artifact-url:hover, .task-panel-output-url:hover,
.task-panel-artifact-url:focus-visible { .task-panel-output-url:focus-visible {
color: #1d4ed8; color: #1d4ed8;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
} }
.task-panel-artifact-url:focus-visible { .task-panel-output-url:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.34); outline: 2px solid rgba(37, 99, 235, 0.34);
outline-offset: 2px; outline-offset: 2px;
border-radius: 6px; }
.task-panel-output-meta {
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.task-panel-output-meta > div {
display: grid;
gap: 2px;
}
.task-panel-output-meta > div strong,
.task-panel-output-meta > div span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-panel-output-meta > div strong {
color: #1c324d;
font-size: 12.5px;
font-weight: 700;
line-height: 1.35;
}
.task-panel-output-meta > div span {
color: #64748b;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
}
.task-panel-output-empty {
min-height: 180px;
} }
.task-panel-artifact-copied { .task-panel-artifact-copied {
position: static; flex: 0 0 auto;
padding: 5px 9px;
border-radius: 999px;
border: 1px solid rgba(167, 243, 208, 0.9);
background: rgba(240, 253, 250, 0.96);
color: #047857; color: #047857;
font-size: 12px; font-size: 12px;
font-weight: 650; font-weight: 650;
line-height: 1.5; line-height: 1;
text-align: right; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
pointer-events: none;
white-space: nowrap; white-space: nowrap;
} }
.task-panel-loading-state { .task-panel-loading-state {
display: grid; display: grid;
gap: 6px; gap: 6px;
min-width: 860px; min-width: 0;
} }
.task-panel-loading-row { .task-panel-loading-row {
display: grid; display: grid;
grid-template-columns: 32px 106px 150px 138px minmax(280px, 1fr); grid-template-columns: 38px repeat(4, minmax(0, 1fr));
gap: 12px; gap: 12px;
align-items: center; align-items: center;
min-height: 82px; min-height: 82px;
...@@ -657,8 +744,16 @@ ...@@ -657,8 +744,16 @@
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.task-panel-scroll { .task-panel-stats {
overflow-x: auto; grid-template-columns: repeat(2, minmax(0, 1fr));
}
.task-panel-output-item {
grid-template-columns: 30px minmax(0, 1fr);
}
.task-panel-output-meta {
grid-column: 2;
} }
} }
...@@ -677,33 +772,30 @@ ...@@ -677,33 +772,30 @@
width: fit-content; width: fit-content;
} }
.task-panel-table, .task-panel-stats {
.task-panel-loading-state { grid-template-columns: minmax(0, 1fr);
min-width: 0;
} }
.task-panel-row { .task-panel-stat-card {
grid-template-columns: minmax(0, 1fr); min-height: 78px;
gap: 10px;
min-height: 0;
} }
.task-panel-row-head { .task-panel-output-item {
display: none; grid-template-columns: minmax(0, 1fr);
} }
.task-panel-row:not(.task-panel-row-head) > div:nth-child(3) { .task-panel-output-icon,
padding-left: 0; .task-panel-output-meta {
grid-column: 1;
} }
.task-panel-loading-row { .task-panel-output-icon {
grid-template-columns: 32px minmax(0, 1fr); width: 30px;
height: 30px;
} }
.task-panel-loading-line-task, .task-panel-loading-row {
.task-panel-loading-pill, grid-template-columns: 38px minmax(0, 1fr);
.task-panel-loading-line-artifact {
grid-column: 1 / -1;
} }
} }
...@@ -716,12 +808,12 @@ ...@@ -716,12 +808,12 @@
animation: none; animation: none;
} }
.task-panel-row { .task-panel-output-item {
transition: none; transition: none;
} }
.task-panel-row:not(.task-panel-row-head):hover, .task-panel-output-item:hover,
.task-panel-row:not(.task-panel-row-head):focus-within { .task-panel-output-item:focus-within {
transform: none; transform: none;
} }
} }
...@@ -861,6 +861,7 @@ export interface TaskPanelArtifact { ...@@ -861,6 +861,7 @@ export interface TaskPanelArtifact {
id: string; id: string;
name: string; name: string;
kind?: string; kind?: string;
summary?: string;
url?: string; url?: string;
} }
...@@ -871,6 +872,9 @@ export interface TaskPanelItem { ...@@ -871,6 +872,9 @@ export interface TaskPanelItem {
taskTitle: string; taskTitle: string;
status: TaskPanelStatus; status: TaskPanelStatus;
statusDetail: string; statusDetail: string;
creditsUsed?: number;
messageCount?: number;
updatedAt?: string;
artifacts: TaskPanelArtifact[]; artifacts: TaskPanelArtifact[];
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment