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({
<nav className="nav-list" aria-label="主导航">
{[
{ id: "chat" as const, label: "对话" },
{ id: "tasks" as const, label: "任务面板" },
{ id: "tasks" as const, label: "工作台" },
{ id: "knowledge" as const, label: ui.knowledge },
{ id: "plugins" as const, label: ui.plugins },
{ id: "settings" as const, label: ui.settings }
......
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import type { TaskPanelArtifact, TaskPanelItem, TaskPanelStatus } from "@qjclaw/shared-types"
import { useEffect, useMemo, useRef, useState } from "react"
import type { TaskPanelArtifact, TaskPanelItem } from "@qjclaw/shared-types"
import { renderExpertIcon } from "../../components/icons/AppIcons"
import { Panel } from "../../components/ui/Panel"
import { ScrollArea } from "../../components/ui/ScrollArea"
import type { ExpertVisualKey } from "../shell/ExpertTree"
import { getDefaultTaskPanelDate, loadTaskPanelItems } from "./taskPanelData"
const statusLabels: Record<TaskPanelStatus, string> = {
pending: "待处理",
running: "执行中",
completed: "已完成",
failed: "失败"
}
import { getDefaultTaskPanelDate, loadTaskPanelItems, summarizeTaskPanelItems } from "./taskPanelData"
function resolveTaskExpertIconKey(expertName: string): ExpertVisualKey {
const seed = expertName.toLowerCase()
......@@ -55,70 +47,109 @@ function resolveTaskExpertIconKey(expertName: string): ExpertVisualKey {
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 (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<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" />
</svg>
)
}
if (kind === "messages") {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<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" />
<path d="M8.75 8.4h6.5M8.75 11.25h4.5" fill="none" stroke="currentColor" strokeLinecap="round" strokeWidth="1.6" />
</svg>
)
}
if (kind === "artifacts") {
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 (
<div className={"task-panel-status task-panel-status-" + item.status}>
<span className="task-panel-status-icon" title={item.statusDetail} aria-hidden="true">
{item.status === "completed" ? <span></span> : null}
{item.status === "running" ? (
<span className="task-panel-running-dots" aria-hidden="true">
<span />
<span />
<span />
</span>
) : null}
{item.status === "pending" ? <span></span> : null}
{item.status === "failed" ? <span>!</span> : null}
</span>
<strong className="task-panel-status-text" title={item.statusDetail}>{statusLabels[item.status]}</strong>
</div>
<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({
artifacts,
copiedArtifactId,
onCopy
}: {
artifacts: TaskPanelArtifact[]
copiedArtifactId: string
onCopy: (artifactId: string, artifactUrl: string) => void
}) {
function TaskPanelOutputIcon({ artifact }: { artifact: TaskPanelArtifact }) {
const normalizedKind = [artifact.kind, artifact.name, artifact.url].filter(Boolean).join(" ").toLowerCase()
if (/视频|video|mp4|mov|m4v|avi|webm/.test(normalizedKind)) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<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" />
<path d="m9.6 9.1 3.7 2.4-3.7 2.4V9.1Z" fill="currentColor" />
</svg>
)
}
if (/表格|xlsx|csv|sheet/.test(normalizedKind)) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<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" />
<path d="M5.9 9.25h12.2M5.9 13h12.2M10 9.25v10.8" fill="none" stroke="currentColor" strokeLinecap="round" strokeWidth="1.45" />
</svg>
)
}
return (
<ul className="task-panel-artifact-list" aria-label="产物清单">
{artifacts.map((artifact) => (
<li
key={artifact.id}
className={copiedArtifactId === artifact.id ? "task-panel-artifact-item-copied" : undefined}
title={artifact.url ?? artifact.name}
>
<span className="task-panel-artifact-name" title={artifact.name}>{artifact.name}</span>
{artifact.url ? (
<button
type="button"
className="task-panel-artifact-url"
title={artifact.url}
onClick={() => onCopy(artifact.id, artifact.url ?? "")}
>
{artifact.url}
</button>
) : null}
{copiedArtifactId === artifact.id ? (
<span className="task-panel-artifact-copied" aria-live="polite">已复制</span>
) : null}
</li>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<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" />
<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" />
</svg>
)
}
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 [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)
useEffect(() => {
......@@ -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(() => {
setIsMenuOpen(false)
setCopiedArtifactId("")
}, [item.id])
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])
}, [outputs])
const copyArtifactUrl = async (artifactId: string, artifactUrl: string) => {
try {
......@@ -218,65 +180,61 @@ function TaskArtifacts({ item }: { item: TaskPanelItem }) {
}
}
if (!item.artifacts.length) {
return (
<div className="task-panel-artifact-center">
<span className="task-panel-muted">暂无产物</span>
</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>
)
}
return (
<div className="task-panel-artifact-menu" ref={menuRef}>
<button
ref={triggerRef}
type="button"
className="task-panel-artifact-trigger"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
aria-controls={item.id + "-artifact-menu"}
onClick={() => {
if (!isMenuOpen) {
updatePopoverPosition()
}
setIsMenuOpen((current) => !current)
}}
>
{item.artifacts.length} 个产物
</button>
{isMenuOpen && popoverPosition ? createPortal((
<div
ref={popoverRef}
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)}
/>
<section className="task-panel-output-panel" aria-label="内容产出列表">
<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>
), document.body) : null}
</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">
<span className="task-panel-output-icon" aria-hidden="true">
<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
type="button"
className="task-panel-output-url"
title={artifact.url}
onClick={() => void copyArtifactUrl(artifact.id, artifact.url ?? "")}
>
{artifact.url}
</button>
{copiedArtifactId === artifact.id ? (
<span className="task-panel-artifact-copied" aria-live="polite">✅已复制</span>
) : null}
</div>
) : null}
</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() {
export function TaskPanelView() {
const [selectedDate, setSelectedDate] = useState(getDefaultTaskPanelDate)
const [items, setItems] = useState<TaskPanelItem[]>([])
const [selectedTaskIds, setSelectedTaskIds] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [errorText, setErrorText] = useState("")
const [greetingText, setGreetingText] = useState("")
......@@ -353,22 +310,9 @@ export function TaskPanelView() {
const displayDate = useMemo(() => selectedDate.replaceAll("-", "/"), [selectedDate])
const displayMonth = useMemo(() => `${Number(selectedDate.slice(5, 7))}月`, [selectedDate])
const expertRows = useMemo(() => {
const groups = new Map<string, TaskPanelItem[]>()
for (const item of 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])
const outputItems = useMemo<TaskPanelOutputItem[]>(() => {
return items.flatMap((task) => task.artifacts.map((artifact) => ({ artifact, task })))
}, [items])
return (
<div className="page-stack task-panel-page-stack">
......@@ -417,47 +361,9 @@ export function TaskPanelView() {
{!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="task-panel-table" role="table" aria-label="任务面板">
<div className="task-panel-row task-panel-row-head" role="row">
<div role="columnheader">数字员工</div>
<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 className="task-panel-content">
<TaskPanelStatCards items={items} />
<TaskPanelOutputList outputs={outputItems} />
</div>
) : null}
</ScrollArea>
......
import type { TaskPanelItem } from "@qjclaw/shared-types"
export interface TaskPanelSummary {
creditsUsed: number
messageCount: number
artifactCount: number
employeeCount: number
}
function toDateInputValue(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
......@@ -25,12 +32,15 @@ export const mockTaskPanelItems: TaskPanelItem[] = [
taskTitle: "整理本周选题方向与发布节奏",
status: "running",
statusDetail: "正在汇总账号定位、目标人群和栏目节奏",
creditsUsed: 1280,
messageCount: 36,
updatedAt: "10:42",
artifacts: [
{ id: "artifact-content-outline", name: "选题规划草稿.md", kind: "文档", 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-persona", name: "目标人群画像.md", kind: "文档", 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-brief", name: "账号定位简报.pdf", kind: "文档", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/账号定位简报.pdf" }
{ id: "artifact-content-outline", name: "选题规划草稿.md", kind: "文档", summary: "本周内容主线、栏目节奏与素材需求草案。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/选题规划草稿.md" },
{ id: "artifact-content-calendar", name: "发布日历.xlsx", kind: "表格", summary: "按平台拆分的发布排期与负责人视图。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/发布日历.xlsx" },
{ id: "artifact-content-persona", name: "目标人群画像.md", kind: "文档", summary: "核心受众痛点、决策因素与内容偏好。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/目标人群画像.md" },
{ id: "artifact-content-topics", name: "栏目选题池.csv", kind: "表格", summary: "可复用选题、关键词和参考链接集合。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/栏目选题池.csv" },
{ id: "artifact-content-brief", name: "账号定位简报.pdf", kind: "文档", summary: "账号定位、差异化表达和近期目标摘要。", url: "/Users/edy/Documents/qianjiangclaw/tasks/content/账号定位简报.pdf" }
]
},
{
......@@ -40,8 +50,11 @@ export const mockTaskPanelItems: TaskPanelItem[] = [
taskTitle: "复盘昨日内容表现",
status: "completed",
statusDetail: "已完成互动数据摘要与优化建议",
creditsUsed: 640,
messageCount: 18,
updatedAt: "09:18",
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[] = [
taskTitle: "生成知乎回答结构",
status: "completed",
statusDetail: "已完成回答大纲与首版正文",
creditsUsed: 920,
messageCount: 24,
updatedAt: "11:05",
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[] = [
taskTitle: "整理竞品问答素材",
status: "running",
statusDetail: "正在提取高赞回答结构和关键词",
creditsUsed: 760,
messageCount: 21,
updatedAt: "11:36",
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[] = [
taskTitle: "筛选高意向线索名单",
status: "pending",
statusDetail: "等待线索表上传后开始处理",
creditsUsed: 0,
messageCount: 4,
updatedAt: "12:10",
artifacts: []
},
{
......@@ -82,12 +104,47 @@ export const mockTaskPanelItems: TaskPanelItem[] = [
taskTitle: "生成活动海报文案",
status: "failed",
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: [
{ 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[]> {
return mockTaskPanelItems.filter((item) => item.date === date)
}
......@@ -170,6 +170,12 @@
padding-right: 2px;
}
.task-panel-scroll .scroll-area-content {
display: grid;
min-height: 0;
overflow: hidden;
}
.task-panel-state {
display: grid;
min-height: 220px;
......@@ -183,62 +189,89 @@
border-color: rgba(203, 213, 225, 0.82);
}
.task-panel-table {
.task-panel-content {
display: grid;
gap: 6px;
min-width: 860px;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
height: 100%;
min-width: 0;
min-height: 0;
}
.task-panel-row {
.task-panel-stats {
display: grid;
grid-template-columns: 150px 150px 138px minmax(280px, 1fr);
gap: 12px;
align-items: center;
min-height: 82px;
padding: 12px 14px;
overflow: visible;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
min-width: 0;
}
.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: 1px solid rgba(226, 232, 240, 0.96);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.035);
transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, transform 160ms ease;
color: #2563eb;
background: #eff6ff;
border: 1px solid #bfdbfe;
}
.task-panel-row-head {
min-height: 36px;
align-items: center;
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-stat-icon svg {
width: 21px;
height: 21px;
}
.task-panel-row:not(.task-panel-row-head):hover,
.task-panel-row:not(.task-panel-row-head):focus-within {
border-color: rgba(147, 197, 253, 0.86);
background: #ffffff;
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.07);
transform: translateY(-1px);
.task-panel-stat-card-messages .task-panel-stat-icon,
.task-panel-stat-card-employees .task-panel-stat-icon {
color: #0f766e;
background: #ecfdf5;
border-color: #a7f3d0;
}
.task-panel-row > div {
min-width: 0;
.task-panel-stat-card-artifacts .task-panel-stat-icon {
color: #0891b2;
background: #ecfeff;
border-color: #a5f3fc;
}
.task-panel-row:not(.task-panel-row-head) > div:nth-child(3) {
padding-left: 14px;
.task-panel-stat-label {
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) {
display: flex;
position: relative;
align-items: center;
justify-content: flex-start;
padding-left: 0;
.task-panel-stat-value {
align-self: end;
min-width: 0;
color: #17253d;
font-size: 24px;
font-weight: 780;
line-height: 1.1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-panel-expert-cell {
......@@ -300,41 +333,6 @@
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 {
display: inline-flex;
align-items: center;
......@@ -420,165 +418,254 @@
border: 1px solid #fecaca;
}
.task-panel-artifact-list {
.task-panel-output-panel {
display: grid;
gap: 5px;
width: 100%;
margin: 0;
padding: 0;
list-style: none;
min-width: 0;
overflow: visible;
}
.task-panel-artifact-center {
display: flex;
width: 100%;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
min-width: 0;
align-items: center;
justify-content: flex-start;
min-height: 0;
}
.task-panel-artifact-menu {
position: relative;
display: flex;
width: 100%;
min-width: 0;
align-items: center;
justify-content: flex-start;
z-index: 4;
.task-panel-output-header {
display: grid;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(219, 234, 254, 0.82);
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
}
.task-panel-artifact-trigger {
.task-panel-output-heading {
display: inline-flex;
min-height: 32px;
align-items: center;
justify-content: center;
padding: 0 12px;
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;
gap: 12px;
min-width: 0;
}
.task-panel-artifact-trigger:hover,
.task-panel-artifact-trigger:focus-visible,
.task-panel-artifact-trigger[aria-expanded="true"] {
border-color: #93c5fd;
color: #1d4ed8;
.task-panel-output-heading-icon {
display: grid;
width: 42px;
height: 42px;
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 {
outline: 2px solid rgba(37, 99, 235, 0.28);
outline-offset: 2px;
.task-panel-output-header h2 {
margin: 0;
color: #17253d;
font-size: 16px;
font-weight: 780;
line-height: 1.35;
}
.task-panel-artifact-popover {
position: fixed;
.task-panel-output-list {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 6px;
border-radius: 12px;
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;
overscroll-behavior: contain;
padding-right: 4px;
}
.task-panel-artifact-popover .task-panel-artifact-list {
width: 100%;
}
.task-panel-artifact-list li {
.task-panel-output-item {
display: grid;
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;
gap: 8px;
min-width: 0;
min-height: 32px;
padding: 5px 8px;
border-radius: 9px;
background: rgba(248, 250, 252, 0.88);
border: 1px solid transparent;
transition: border-color 160ms ease, background 160ms ease;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(226, 232, 240, 0.96);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.035);
transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.task-panel-artifact-list li:hover,
.task-panel-artifact-list li:focus-within {
border-color: #dbeafe;
.task-panel-output-item:hover,
.task-panel-output-item:focus-within {
border-color: rgba(147, 197, 253, 0.86);
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 {
padding-right: 8px;
.task-panel-output-icon svg {
width: 18px;
height: 18px;
}
.task-panel-artifact-name,
.task-panel-artifact-url {
.task-panel-output-main,
.task-panel-output-meta,
.task-panel-output-meta > div {
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;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-panel-artifact-name {
color: #1f2f49;
.task-panel-output-kind {
flex: 0 0 auto;
padding: 3px 8px;
border-radius: 999px;
color: #0f766e;
background: #ccfbf1;
font-size: 12px;
font-weight: 750;
line-height: 1;
white-space: nowrap;
}
.task-panel-artifact-url {
color: #60728c;
.task-panel-output-main p {
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;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: #60728c;
font: inherit;
font-size: 12.5px;
font-weight: 600;
line-height: 1.45;
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-panel-artifact-url:hover,
.task-panel-artifact-url:focus-visible {
.task-panel-output-url:hover,
.task-panel-output-url:focus-visible {
color: #1d4ed8;
text-decoration: underline;
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-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 {
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;
font-size: 12px;
font-weight: 650;
line-height: 1.5;
text-align: right;
line-height: 1;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
pointer-events: none;
white-space: nowrap;
}
.task-panel-loading-state {
display: grid;
gap: 6px;
min-width: 860px;
min-width: 0;
}
.task-panel-loading-row {
display: grid;
grid-template-columns: 32px 106px 150px 138px minmax(280px, 1fr);
grid-template-columns: 38px repeat(4, minmax(0, 1fr));
gap: 12px;
align-items: center;
min-height: 82px;
......@@ -657,8 +744,16 @@
}
@media (max-width: 980px) {
.task-panel-scroll {
overflow-x: auto;
.task-panel-stats {
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 @@
width: fit-content;
}
.task-panel-table,
.task-panel-loading-state {
min-width: 0;
.task-panel-stats {
grid-template-columns: minmax(0, 1fr);
}
.task-panel-row {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
min-height: 0;
.task-panel-stat-card {
min-height: 78px;
}
.task-panel-row-head {
display: none;
.task-panel-output-item {
grid-template-columns: minmax(0, 1fr);
}
.task-panel-row:not(.task-panel-row-head) > div:nth-child(3) {
padding-left: 0;
.task-panel-output-icon,
.task-panel-output-meta {
grid-column: 1;
}
.task-panel-loading-row {
grid-template-columns: 32px minmax(0, 1fr);
.task-panel-output-icon {
width: 30px;
height: 30px;
}
.task-panel-loading-line-task,
.task-panel-loading-pill,
.task-panel-loading-line-artifact {
grid-column: 1 / -1;
.task-panel-loading-row {
grid-template-columns: 38px minmax(0, 1fr);
}
}
......@@ -716,12 +808,12 @@
animation: none;
}
.task-panel-row {
.task-panel-output-item {
transition: none;
}
.task-panel-row:not(.task-panel-row-head):hover,
.task-panel-row:not(.task-panel-row-head):focus-within {
.task-panel-output-item:hover,
.task-panel-output-item:focus-within {
transform: none;
}
}
......@@ -861,6 +861,7 @@ export interface TaskPanelArtifact {
id: string;
name: string;
kind?: string;
summary?: string;
url?: string;
}
......@@ -871,6 +872,9 @@ export interface TaskPanelItem {
taskTitle: string;
status: TaskPanelStatus;
statusDetail: string;
creditsUsed?: number;
messageCount?: number;
updatedAt?: string;
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