feat: built-in Worktable console project with monitor panel and theme
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { css } from './styles'
|
||||
import { NS, zh, en, type WorktableKey } from './locales'
|
||||
import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane } from './split'
|
||||
import { splitStore, SplitWorkspace, setSplitT, setSplitEnv, type LayoutSpec, type SplitPane, type ConsoleCardData } from './split'
|
||||
|
||||
/**
|
||||
* dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。
|
||||
@@ -25,6 +25,8 @@ type ViewState = {
|
||||
orderBy: OrderBy
|
||||
dock: DockMode
|
||||
floatTop: number | null
|
||||
/** 控制室面板主题:dark/light/system(system = 跟随宿主 color-scheme,即 DSH 深色/白色/跟随系统设置) */
|
||||
consoleTheme?: 'dark' | 'light' | 'system'
|
||||
}
|
||||
|
||||
/** 卡片上报的项目元信息(协议 v2)。 */
|
||||
@@ -249,6 +251,7 @@ function loadView(): ViewState {
|
||||
orderBy,
|
||||
dock: p.dock === 'float' ? 'float' : 'footer',
|
||||
floatTop: typeof p.floatTop === 'number' ? p.floatTop : null,
|
||||
consoleTheme: p.consoleTheme === 'dark' || p.consoleTheme === 'light' || p.consoleTheme === 'system' ? p.consoleTheme : 'system',
|
||||
}
|
||||
} catch {
|
||||
return { ...DEFAULT_VIEW }
|
||||
@@ -304,6 +307,64 @@ const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [],
|
||||
/** 自定义窗口 → 宿主会话桥(apply 时注入;不可用时 CustomPane 降级提示) */
|
||||
let sessionBridge: { sessions: any; conversation: any; list: any; workspaces: any } | null = null
|
||||
|
||||
/** 控制室(工作台自己)项目:固定 id、固定排项目列表第一位、不可删除 */
|
||||
const CONSOLE_ID = 'wt-console'
|
||||
const CONSOLE_ICON = '🖥️'
|
||||
|
||||
/** 控制室默认布局:单一锁死大窗格(项目卡片网格)+ 右侧对话 */
|
||||
function buildConsoleSpec(): LayoutSpec {
|
||||
return {
|
||||
id: CONSOLE_ID,
|
||||
title: '工作台',
|
||||
icon: CONSOLE_ICON,
|
||||
top: null,
|
||||
main: [{
|
||||
id: 'console',
|
||||
title: '控制室',
|
||||
min: 240,
|
||||
tabs: [{ id: 'c1', title: '控制室', content: { kind: 'builtin', type: 'console' } }],
|
||||
active: 0,
|
||||
}],
|
||||
chatWidth: { default: 340, min: 280, max: 600 },
|
||||
topHeight: { default: 200, min: 120, max: 480 },
|
||||
chatSide: 'right',
|
||||
}
|
||||
}
|
||||
|
||||
/** 控制室面板刷新总线(项目/会话快照变化时推送,ConsolePane 订阅重渲染) */
|
||||
const consoleListeners = new Set<() => void>()
|
||||
const notifyConsole = () => consoleListeners.forEach((l) => { try { l() } catch {} })
|
||||
|
||||
/** 宿主工作区列表(分组选择数据源;读 ctx.workspaces 快照) */
|
||||
function listWorkspaces(): { id: string; title: string; path: string }[] {
|
||||
try {
|
||||
const snap = sessionBridge?.workspaces?.list?.getSnapshot?.()
|
||||
const items = snap?.items ?? []
|
||||
return items.map((w: any) => ({
|
||||
id: w.workspaceId ?? w.id,
|
||||
title: w.title ?? '',
|
||||
path: w.path ?? '',
|
||||
}))
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
/** 从会话快照节点里提取最近一条文本(纯读内存镜像,零 Token;无则 '') */
|
||||
function lastTextOf(sid: string): string {
|
||||
try {
|
||||
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
|
||||
const nodes: any[] = face?.nodes ?? []
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const n = nodes[i]
|
||||
const blocks = Array.isArray(n?.blocks) ? n.blocks : (Array.isArray(n?.content) ? n.content : [])
|
||||
for (const b of blocks) {
|
||||
const s = typeof b?.text === 'string' ? b.text.trim().replace(/\s+/g, ' ') : ''
|
||||
if (s) return s
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 会话分组列表(模块级,供自定义窗会话选择与对话绑定弹窗共用) */
|
||||
async function fetchSessionGroups(): Promise<{ groups: { title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]; current: string }> {
|
||||
try {
|
||||
@@ -715,6 +776,15 @@ function WorktableSection(props: any) {
|
||||
const [bindPick, setBindPick] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||
const [bindGroups, setBindGroups] = useState<{ title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]>([])
|
||||
const [bindListOpen, setBindListOpen] = useState(false)
|
||||
/** 控制室强制绑定弹窗:未绑定点开「工作台」时弹出(左加入现有对话 / 右新建对话) */
|
||||
const [consoleBind, setConsoleBind] = useState<{ x: number; y: number } | null>(null)
|
||||
const [consoleGroups, setConsoleGroups] = useState<{ title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[]>([])
|
||||
const [consoleMode, setConsoleMode] = useState<'none' | 'existing' | 'new'>('none')
|
||||
const [consoleWsId, setConsoleWsId] = useState('')
|
||||
const [consoleParent, setConsoleParent] = useState('')
|
||||
const [consoleName, setConsoleName] = useState('')
|
||||
const [consoleErr, setConsoleErr] = useState(false)
|
||||
const [consoleBusy, setConsoleBusy] = useState(false)
|
||||
|
||||
/** 自定义布局弹窗(预设网格末尾的 + 磁贴):描述 → 复制提示词到剪贴板 */
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
@@ -743,6 +813,102 @@ function WorktableSection(props: any) {
|
||||
const projectsRef = useRef<{ projects: ProjectsState; metas: Record<string, ProjectMeta>; aliveRegisteredIds: string[] }>(
|
||||
{ projects, metas, aliveRegisteredIds: registeredIds },
|
||||
)
|
||||
/** 控制室动作引用(每渲染同步,供 env 闭包读取) */
|
||||
const actionsRef = useRef<{ openSplit: (spec: LayoutSpec) => void; openConsole: (explicitBound?: string | null) => void } | null>(null)
|
||||
/** 视图状态最新快照(供 env 闭包读取控制室主题) */
|
||||
const viewRef = useRef(view)
|
||||
viewRef.current = view
|
||||
|
||||
/** 控制室卡片数据组装(env 闭包调用;只读镜像:状态/时长/子代理/预览全部来自宿主内存快照,零轮询零 Token) */
|
||||
const getConsoleCards = (): ConsoleCardData[] => {
|
||||
const pr = projectsRef.current
|
||||
const snap = sessionsSnapshotStore.snapshot
|
||||
const byId: Record<string, any> = snap?.byId ?? {}
|
||||
const jobsMap: Record<string, any[]> = snap?.jobsBySession ?? {}
|
||||
const now = Date.now()
|
||||
const kidsSetOf = (sid: string): Set<string> => {
|
||||
const set = new Set<string>()
|
||||
for (const [cid, ce] of Object.entries<any>(byId)) if (ce?.parentId === sid) set.add(cid)
|
||||
const v = (snap?.subagentsByParent ?? {})[sid]
|
||||
const arr = Array.isArray(v) ? v : (v?.entries ?? v?.items ?? [])
|
||||
if (Array.isArray(arr)) arr.forEach((c: any) => {
|
||||
const cid = c?.sessionId ?? c?.id
|
||||
if (typeof cid === 'string') set.add(cid)
|
||||
})
|
||||
return set
|
||||
}
|
||||
// 三态判定(同卡片提醒逻辑,但不过滤 ack——监控室永远显示事实状态)
|
||||
const statusOf = (sid: string | undefined): ConsoleCardData['status'] => {
|
||||
if (!sid) return 'idle'
|
||||
const e = byId[sid]
|
||||
if (!e) return 'idle'
|
||||
if (sessionNotifyState(e) === 'need') return 'need'
|
||||
for (const cid of kidsSetOf(sid)) {
|
||||
if (byId[cid] && sessionNotifyState(byId[cid]) === 'need') return 'need'
|
||||
}
|
||||
try {
|
||||
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
|
||||
if (Array.isArray(face?.pending) && face.pending.length > 0) return 'need'
|
||||
} catch {}
|
||||
if (e.completed === true) return 'done'
|
||||
if (e.running === true) return 'busy'
|
||||
return 'idle'
|
||||
}
|
||||
// 运行时长:本会话正在运行的后台任务最早 startedAt 起算;无任务时读会话面 turnTimings 未结束轮次
|
||||
const runtimeOf = (sid: string | undefined): number | null => {
|
||||
if (!sid) return null
|
||||
const e = byId[sid]
|
||||
if (!e || e.running !== true) return null
|
||||
let start: number | null = null
|
||||
const jobs = jobsMap[sid] ?? []
|
||||
for (const j of jobs) {
|
||||
if (j?.status === 'running' && typeof j.startedAt === 'number' && (start == null || j.startedAt < start)) start = j.startedAt
|
||||
}
|
||||
if (start != null) return Math.max(0, now - start)
|
||||
try {
|
||||
const face = sessionBridge?.sessions?.binding?.(sid)?.session?.getSnapshot?.()
|
||||
const timings = face?.turnTimings
|
||||
if (timings instanceof Map) {
|
||||
for (const t of Array.from(timings.values()).reverse()) {
|
||||
if (t && typeof t.startTime === 'number' && t.endTime == null) return Math.max(0, now - t.startTime)
|
||||
}
|
||||
} else if (timings && typeof timings === 'object') {
|
||||
for (const k of Object.keys(timings).reverse()) {
|
||||
const t = (timings as any)[k]
|
||||
if (t && typeof t.startTime === 'number' && t.endTime == null) return Math.max(0, now - t.startTime)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
const make = (id: string, name: string, icon: string, self: boolean): ConsoleCardData => {
|
||||
const sid = pr.projects.bindings[id]
|
||||
return {
|
||||
id, name, icon,
|
||||
status: statusOf(sid),
|
||||
runtimeMs: runtimeOf(sid),
|
||||
kids: sid ? kidsSetOf(sid).size : 0,
|
||||
preview: sid ? lastTextOf(sid) : '',
|
||||
bound: !!sid,
|
||||
self,
|
||||
}
|
||||
}
|
||||
const cards: ConsoleCardData[] = []
|
||||
cards.push(make(CONSOLE_ID, t('console.name'), CONSOLE_ICON, true))
|
||||
const ids = [...pr.aliveRegisteredIds, ...pr.projects.layouts.map((l) => l.id)]
|
||||
const known = new Set(ids)
|
||||
const stored = pr.projects.order.filter((id) => known.has(id))
|
||||
const ordered = [...stored, ...ids.filter((id) => !stored.includes(id))].filter((id) => id !== CONSOLE_ID)
|
||||
for (const id of ordered) {
|
||||
const meta = pr.metas[id]
|
||||
const layout = pr.projects.layouts.find((l) => l.id === id)
|
||||
if (!meta && !layout) continue
|
||||
const name = pr.projects.nameOverrides[id] ?? meta?.name ?? layout?.title ?? id
|
||||
const icon = meta?.icon ?? layout?.icon ?? '🧱'
|
||||
cards.push(make(id, name, icon, false))
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
// 会话作用域(当前会话 + 工作目录)与后台任务:注入分栏引擎环境
|
||||
// 注意:不走 props.useSessions hook(其宿主包装在部分版本会触发 useSyncExternalStore
|
||||
@@ -793,6 +959,31 @@ function WorktableSection(props: any) {
|
||||
return 'auto'
|
||||
},
|
||||
},
|
||||
// 控制室:卡片数据订阅 + 打开项目 / 跳绑定对话
|
||||
console: {
|
||||
subscribe: (fn) => { consoleListeners.add(fn); return () => { consoleListeners.delete(fn) } },
|
||||
getCards: () => getConsoleCards(),
|
||||
getTheme: () => viewRef.current.consoleTheme ?? 'system',
|
||||
setTheme: (th: 'dark' | 'light' | 'system') => persistView({ consoleTheme: th }),
|
||||
onOpen: (id) => {
|
||||
if (id === CONSOLE_ID) return
|
||||
const pr = projectsRef.current.projects
|
||||
const layout = pr.layouts.find((l) => l.id === id)
|
||||
const view = pr.views[id]
|
||||
if (view || layout) actionsRef.current?.openSplit((view ?? layout) as LayoutSpec)
|
||||
else {
|
||||
// 入驻项目无视图覆盖:仅切换其绑定对话(对齐卡片自带打开行为)
|
||||
const bound = pr.bindings[id]
|
||||
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
|
||||
}
|
||||
},
|
||||
onJump: (id) => {
|
||||
const sid = projectsRef.current.projects.bindings[id]
|
||||
if (!sid) return
|
||||
markPluginSessionOpen(sid) // 插件发起的切换:不触发「切会话关项目」联动
|
||||
try { sessionBridge?.sessions?.open?.(sid) } catch {}
|
||||
},
|
||||
},
|
||||
}
|
||||
setSplitEnv(env)
|
||||
try { (window as any).__dshCustomEnv = env } catch {}
|
||||
@@ -853,7 +1044,7 @@ function WorktableSection(props: any) {
|
||||
|
||||
// 分栏引擎 UI 文案(窗选择器等)
|
||||
useEffect(() => {
|
||||
setSplitT((k) => t(k as WorktableKey))
|
||||
setSplitT((k, p) => t(k as WorktableKey, p as Record<string, string> | undefined))
|
||||
return () => setSplitT(null)
|
||||
}, [t])
|
||||
|
||||
@@ -1033,6 +1224,80 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
}
|
||||
}, [projects.views])
|
||||
|
||||
/** 打开「工作台」控制室:已绑定 → 打开并切换绑定对话;explicitBound 供强制绑定流程传入刚绑定的会话 */
|
||||
const openConsole = useCallback((explicitBound?: string | null) => {
|
||||
const spec = projects.views[CONSOLE_ID] ?? buildConsoleSpec()
|
||||
engineIdsRef.current.add(CONSOLE_ID)
|
||||
splitStore.open(spec)
|
||||
if (splitStore.active && splitStore.spec?.id === CONSOLE_ID) {
|
||||
let prev: string | null = null
|
||||
try { prev = sessionBridge?.list?.getSnapshot?.()?.current ?? null } catch {}
|
||||
projectAttachRef.sessionId = prev
|
||||
const bound = explicitBound !== undefined ? explicitBound : projectsRef.current.projects.bindings[CONSOLE_ID]
|
||||
projectAttachRef.attached = bound ?? prev
|
||||
if (bound) { try { sessionBridge?.sessions?.open?.(bound) } catch {} }
|
||||
ackProjectNotify(CONSOLE_ID)
|
||||
try { notifyConsole() } catch {}
|
||||
}
|
||||
}, [projects.views])
|
||||
|
||||
/** 控制室卡片点击:已绑定 → 打开控制室;未绑定 → 强制绑定弹窗 */
|
||||
const clickConsoleCard = (anchor: HTMLElement) => {
|
||||
if (projectsRef.current.projects.bindings[CONSOLE_ID]) { openConsole(); return }
|
||||
const r = anchor.getBoundingClientRect()
|
||||
setConsoleBind({
|
||||
x: clamp(Math.round(r.right + 8), 8, window.innerWidth - 640),
|
||||
y: clamp(Math.round(r.top), 8, window.innerHeight - 460),
|
||||
})
|
||||
setConsoleGroups([])
|
||||
fetchSessionGroups().then((res) => setConsoleGroups(res.groups)).catch(() => setConsoleGroups([]))
|
||||
}
|
||||
|
||||
/** 强制绑定:加入现有对话(绑定后直接打开控制室) */
|
||||
const bindConsoleExisting = (sid: string) => {
|
||||
persistProjects((prev) => ({ ...prev, bindings: { ...prev.bindings, [CONSOLE_ID]: sid } }))
|
||||
setConsoleBind(null)
|
||||
openConsole(sid)
|
||||
}
|
||||
|
||||
/** 强制绑定:新建空会话并绑定(分组同自定义窗:无 / 现有 / 新建) */
|
||||
const bindConsoleNew = async () => {
|
||||
const b = sessionBridge
|
||||
if (!b || typeof b.sessions?.create !== 'function') { setConsoleErr(true); return }
|
||||
if (consoleMode === 'new' && (!consoleParent.trim() || !consoleName.trim())) { setConsoleErr(true); return }
|
||||
setConsoleBusy(true); setConsoleErr(false)
|
||||
try {
|
||||
let workspaceId: string | null = null
|
||||
if (consoleMode === 'existing' && consoleWsId) workspaceId = consoleWsId
|
||||
else if (consoleMode === 'new') {
|
||||
const ws = b.workspaces as any
|
||||
const parent = consoleParent.replace(/[\\/]+$/, '')
|
||||
const name = consoleName.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '')
|
||||
const full = parent + '\\' + name
|
||||
try { await ws?.createDirectory?.(parent, name) } catch {}
|
||||
try {
|
||||
const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ parent, name }) })
|
||||
const d = await r.json()
|
||||
if (!r.ok || !d?.ok) throw new Error(d?.error ?? 'mkdir failed')
|
||||
} catch { /* 目录建不出的错误最终由 workspace.create 暴露 */ }
|
||||
const view = await ws.create({ path: full })
|
||||
workspaceId = view?.workspaceId ?? view?.id ?? null
|
||||
if (!workspaceId) throw new Error('workspace create failed')
|
||||
}
|
||||
const folder = projectsRef.current.projects.folders[CONSOLE_ID] ?? null
|
||||
let createOpts: any = {}
|
||||
if (workspaceId) createOpts = { workspaceId }
|
||||
else if (folder) createOpts = { cwd: folder }
|
||||
const sessionId = await b.sessions.create(createOpts)
|
||||
markPluginSessionOpen(sessionId)
|
||||
persistProjects((prev) => ({ ...prev, bindings: { ...prev.bindings, [CONSOLE_ID]: sessionId } }))
|
||||
setConsoleBind(null)
|
||||
openConsole(sessionId)
|
||||
} catch { setConsoleErr(true) } finally { setConsoleBusy(false) }
|
||||
}
|
||||
|
||||
actionsRef.current = { openSplit, openConsole }
|
||||
|
||||
/** 打开对话绑定弹窗:抓取会话分组 + 锚点定位 */
|
||||
const openBindPick = useCallback((id: string, anchor: HTMLElement) => {
|
||||
const r = anchor.getBoundingClientRect()
|
||||
@@ -1101,13 +1366,16 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
copyToastTimerRef.current = window.setTimeout(() => setCopiedToast(null), 6000)
|
||||
}
|
||||
|
||||
// 会话快照变化 → 重渲染(驱动项目卡片的完成/待决提醒镜像)
|
||||
// 会话快照变化 → 重渲染(驱动项目卡片的完成/待决提醒镜像 + 控制室面板)
|
||||
useEffect(() => {
|
||||
const l = () => setNotifyTick((t) => t + 1)
|
||||
const l = () => { setNotifyTick((t) => t + 1); notifyConsole() }
|
||||
sessionsSnapshotStore.listeners.add(l)
|
||||
return () => { sessionsSnapshotStore.listeners.delete(l) }
|
||||
}, [])
|
||||
|
||||
// 项目表变化 → 控制室卡片刷新(打开中的面板即时更新)
|
||||
useEffect(() => { notifyConsole() }, [projects])
|
||||
|
||||
/** 自动挂载:绑定会话完成 → 读项目文件夹 widget-result.json → 产物自动挂进对应窗口 */
|
||||
const tryAutoMount = useCallback(async (projectId: string, sid: string) => {
|
||||
const folder = projectsRef.current.projects.folders[projectId]
|
||||
@@ -1439,7 +1707,9 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
}
|
||||
|
||||
// ── 删除(全部走二次确认;项目彻底移出工作台:对话与项目文件均保留,仅清理本地关联状态) ──
|
||||
// 「工作台」控制室项目不可删除(界面不提供入口,这里兜底拒绝)
|
||||
const removeProject = (id: string) => {
|
||||
if (id === CONSOLE_ID) return
|
||||
persistProjects((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
@@ -2038,6 +2308,65 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 控制室强制绑定弹窗:左「加入现有对话」+ 右「新建对话」,选定/建成后自动打开控制室 */}
|
||||
{consoleBind && <div className="dsh-wt_popBackdrop" style={{ zIndex: 85 }} onClick={() => setConsoleBind(null)} />}
|
||||
{consoleBind && (
|
||||
<div className="dsh-wt_menu dsh-wt_pop dsh-wt_consoleBindPop" style={{ position: 'fixed', left: consoleBind.x, top: consoleBind.y, width: 560, zIndex: 86 }}>
|
||||
<div className="dsh-wt_consoleBindCols">
|
||||
<div className="dsh-wt_consoleBindCol">
|
||||
<span className="dsh-wt_consoleBindLabel">➕ {t('console.joinExisting')}</span>
|
||||
<div className="dsh-wt_selectList dsh-wt_consoleBindList">
|
||||
{consoleGroups.map((g, gi) => (
|
||||
<Fragment key={g.title || 'g' + gi}>
|
||||
{g.title && (
|
||||
<>
|
||||
<div className="dsh-wt_selectDivider" />
|
||||
<div className="dsh-wt_selectGroup">📁 {g.title}</div>
|
||||
</>
|
||||
)}
|
||||
{g.sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={'dsh-wt_selectItem' + (projects.bindings[CONSOLE_ID] === s.id ? ' dsh-wt_selectItemOn' : '')}
|
||||
onClick={() => bindConsoleExisting(s.id)}
|
||||
>
|
||||
<span className="dsh-wt_selectItemTitle">{s.title}</span>
|
||||
{s.isCurrent && <span className="dsh-wt_selectCurrent">{t('custom.sessionCurrent')}</span>}
|
||||
</button>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
{consoleGroups.length === 0 && <div className="dsh-wt_consoleBindEmpty">{t('console.noSessions')}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dsh-wt_consoleBindCol dsh-wt_consoleBindColNew">
|
||||
<span className="dsh-wt_consoleBindLabel">✨ {t('console.newConv')}</span>
|
||||
<select className="dsh-wt_consoleSelect" value={consoleMode} onChange={(e) => setConsoleMode(e.target.value as 'none' | 'existing' | 'new')}>
|
||||
<option value="none">{t('console.groupNone')}</option>
|
||||
<option value="existing">{t('console.groupExisting')}</option>
|
||||
<option value="new">{t('console.groupNew')}</option>
|
||||
</select>
|
||||
{consoleMode === 'existing' && (
|
||||
<select className="dsh-wt_consoleSelect" value={consoleWsId} onChange={(e) => setConsoleWsId(e.target.value)}>
|
||||
{listWorkspaces().map((w) => <option key={w.id} value={w.id}>{w.title}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{consoleMode === 'new' && (
|
||||
<>
|
||||
<input className="dsh-wt_consoleInput" placeholder={t('console.newParentPh')} value={consoleParent} onChange={(e) => setConsoleParent(e.target.value)} />
|
||||
<input className="dsh-wt_consoleInput" placeholder={t('console.newNamePh')} value={consoleName} onChange={(e) => setConsoleName(e.target.value)} />
|
||||
</>
|
||||
)}
|
||||
{consoleErr && <p className="dsh-wt_consoleErr">{t('console.bindFail')}</p>}
|
||||
<button type="button" className="dsh-wt_consoleCreateBtn" disabled={consoleBusy} onClick={bindConsoleNew}>
|
||||
{consoleBusy ? '…' : t('console.createBind')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customOpen && <div className="dsh-wt_popBackdrop" style={{ zIndex: 83 }} onClick={() => setCustomOpen(false)} />}
|
||||
{customOpen && (
|
||||
<div className="dsh-wt_menu dsh-wt_pop" style={{ position: 'fixed', left: popLeft, top: popTop, width: 340, zIndex: 84 }}>
|
||||
@@ -2078,6 +2407,34 @@ function buildCustomLayoutPrompt(req: string): string {
|
||||
)}
|
||||
|
||||
<div className="dsh-wt_projects" data-managing={viewOptionsOpen ? 'true' : undefined}>
|
||||
{/* 「工作台」控制室项目:固定首位、不可删除;未绑定点开 = 强制绑定弹窗 */}
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_layout dsh-wt_consoleEntry"
|
||||
data-on={activeSplitId === CONSOLE_ID ? 'true' : 'false'}
|
||||
style={{ order: 0, position: 'relative' }}
|
||||
title={t('console.name')}
|
||||
onClick={(e) => clickConsoleCard(e.currentTarget as HTMLElement)}
|
||||
>
|
||||
<span className="dsh-wt_layoutIcon">{CONSOLE_ICON}</span>
|
||||
<span className="dsh-wt_layoutText">
|
||||
<span className="dsh-wt_layoutName">{t('console.name')}</span>
|
||||
</span>
|
||||
<span
|
||||
className={'dsh-wt_bindBtn'}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-bound={bindNotifyMap[CONSOLE_ID] ?? (projects.bindings[CONSOLE_ID] ? 'true' : 'false')}
|
||||
data-tip={projects.bindings[CONSOLE_ID]
|
||||
? t('bind.tipBound', { name: boundSessionTitle(projects.bindings[CONSOLE_ID]) })
|
||||
: t('bind.tipUnbound')}
|
||||
aria-label={projects.bindings[CONSOLE_ID]
|
||||
? t('bind.tipBound', { name: boundSessionTitle(projects.bindings[CONSOLE_ID]) })
|
||||
: t('bind.tipUnbound')}
|
||||
onClick={(e) => { e.stopPropagation(); openBindPick(CONSOLE_ID, e.currentTarget as HTMLElement) }}
|
||||
><span className="dsh-wt_bindCircles" aria-hidden /></span>
|
||||
<span className="dsh-wt_layoutArrow" aria-hidden>›</span>
|
||||
</button>
|
||||
{renderProjectSlot
|
||||
? renderProjectSlot('sidebar.worktable.project', ownerProps)
|
||||
: <div className="dsh-wt_empty">{t('empty')}</div>}
|
||||
|
||||
@@ -63,6 +63,7 @@ export const zh = {
|
||||
'pane.terminal': '终端',
|
||||
'pane.custom': '自定义',
|
||||
'pane.anim': '动画',
|
||||
'pane.console': '控制室',
|
||||
'pane.refresh': '刷新',
|
||||
'pane.animUrlPh': '粘贴动画站点地址后回车',
|
||||
'custom.title': '自定义这个窗口',
|
||||
@@ -112,6 +113,31 @@ export const zh = {
|
||||
'customLayout.copyFail': '复制失败,请手动全选输入框内容复制。',
|
||||
'custom.autoBound': '已自动把这个项目绑定到这个对话(卡片上的 ●● 按钮可更换或解绑)。',
|
||||
'custom.keptBinding': '该项目已有绑定的对话,按规则未自动更改。',
|
||||
'console.name': '工作台',
|
||||
'console.title': '控制室',
|
||||
'console.themeLabel': '面板主题',
|
||||
'console.themeDark': '深色',
|
||||
'console.themeLight': '白色',
|
||||
'console.themeSystem': '跟随系统',
|
||||
'console.idle': '空闲',
|
||||
'console.busy': '工作中',
|
||||
'console.need': '待你决定',
|
||||
'console.done': '已完成',
|
||||
'console.kids': '子代理',
|
||||
'console.noPreview': '暂无消息',
|
||||
'console.unboundShort': '未绑定对话',
|
||||
'console.jump': '跳转到绑定对话',
|
||||
'console.empty': '还没有项目',
|
||||
'console.joinExisting': '加入现有对话',
|
||||
'console.newConv': '新建对话',
|
||||
'console.groupNone': '无分组(用项目文件夹)',
|
||||
'console.groupExisting': '加入现有分组',
|
||||
'console.groupNew': '新建分组',
|
||||
'console.newParentPh': '分组父目录,例如 E:\\AI_Workspace',
|
||||
'console.newNamePh': '分组文件夹名(将创建并注册)',
|
||||
'console.createBind': '新建并绑定',
|
||||
'console.bindFail': '创建失败,请重试',
|
||||
'console.noSessions': '没有可用对话',
|
||||
'pane.wip': '开发中(后续版本接入)',
|
||||
'pane.explorerNoCwd': '当前会话没有工作目录',
|
||||
'pane.openLater': '该类型暂不支持预览',
|
||||
@@ -198,6 +224,7 @@ export const en = {
|
||||
'pane.terminal': 'Terminal',
|
||||
'pane.custom': 'Custom',
|
||||
'pane.anim': 'Animation',
|
||||
'pane.console': 'Console',
|
||||
'pane.refresh': 'Refresh',
|
||||
'pane.animUrlPh': 'Paste the animation site URL and press Enter',
|
||||
'custom.title': 'Customize this pane',
|
||||
@@ -247,6 +274,31 @@ export const en = {
|
||||
'customLayout.copyFail': 'Copy failed — select the text above and copy it manually.',
|
||||
'custom.autoBound': 'This project was auto-bound to this conversation (use the ●● button on its card to change or unbind).',
|
||||
'custom.keptBinding': 'This project already has a bound conversation, so it was not auto-changed.',
|
||||
'console.name': 'Worktable',
|
||||
'console.title': 'Console',
|
||||
'console.themeLabel': 'Panel theme',
|
||||
'console.themeDark': 'Dark',
|
||||
'console.themeLight': 'Light',
|
||||
'console.themeSystem': 'System',
|
||||
'console.idle': 'Idle',
|
||||
'console.busy': 'Working',
|
||||
'console.need': 'Needs you',
|
||||
'console.done': 'Done',
|
||||
'console.kids': 'subagents',
|
||||
'console.noPreview': 'No messages yet',
|
||||
'console.unboundShort': 'No conversation bound',
|
||||
'console.jump': 'Jump to bound conversation',
|
||||
'console.empty': 'No projects yet',
|
||||
'console.joinExisting': 'Join existing conversation',
|
||||
'console.newConv': 'New conversation',
|
||||
'console.groupNone': 'No group (use project folder)',
|
||||
'console.groupExisting': 'Join existing group',
|
||||
'console.groupNew': 'New group',
|
||||
'console.newParentPh': 'Group parent folder, e.g. E:\\AI_Workspace',
|
||||
'console.newNamePh': 'Group folder name (created & registered)',
|
||||
'console.createBind': 'Create & bind',
|
||||
'console.bindFail': 'Creation failed, try again',
|
||||
'console.noSessions': 'No sessions available',
|
||||
'pane.wip': 'Coming in a later version',
|
||||
'pane.explorerNoCwd': 'No working directory for the current session',
|
||||
'pane.openLater': 'Preview not supported for this type',
|
||||
|
||||
@@ -23,7 +23,7 @@ hljs.registerLanguage('json', hljsJson)
|
||||
* 内容与 chatSide 的变更经 onSpecMutated 回调交给工作台持久化(布局条目)。
|
||||
*/
|
||||
|
||||
export type BuiltinType = 'browser' | 'anim' | 'explorer' | 'scm' | 'tasks' | 'terminal' | 'custom'
|
||||
export type BuiltinType = 'browser' | 'anim' | 'explorer' | 'scm' | 'tasks' | 'terminal' | 'custom' | 'console'
|
||||
|
||||
export type SplitContent =
|
||||
| { kind: 'iframe'; url: string; title?: string }
|
||||
@@ -131,6 +131,7 @@ const BUILTIN_ICONS: Record<BuiltinType, string> = {
|
||||
tasks: '✅',
|
||||
terminal: '▸_',
|
||||
custom: '✨',
|
||||
console: '🖥️',
|
||||
}
|
||||
|
||||
const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
|
||||
@@ -141,6 +142,7 @@ const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
|
||||
tasks: 'pane.tasks',
|
||||
terminal: 'pane.terminal',
|
||||
custom: 'pane.custom',
|
||||
console: 'pane.console',
|
||||
}
|
||||
|
||||
function tabTitleOf(content: SplitContent): string {
|
||||
@@ -170,11 +172,11 @@ function sameContent(a: SplitContent, b: SplitContent): boolean {
|
||||
}
|
||||
|
||||
/** 分栏 UI 文案提供者(由工作台注入 locale t) */
|
||||
let uiT: ((key: string) => string) | null = null
|
||||
export function setSplitT(fn: ((key: string) => string) | null) {
|
||||
let uiT: ((key: string, params?: Record<string, string>) => string) | null = null
|
||||
export function setSplitT(fn: ((key: string, params?: Record<string, string>) => string) | null) {
|
||||
uiT = fn
|
||||
}
|
||||
const T = (key: string): string => (uiT ? uiT(key) : key)
|
||||
const T = (key: string, params?: Record<string, string>): string => (uiT ? uiT(key, params) : key)
|
||||
|
||||
/** 工作区环境(由工作台注入:当前会话作用域与后台任务列表) */
|
||||
export type SplitScope = { sessionId: string; cwd: string }
|
||||
@@ -187,6 +189,25 @@ export type SplitJob = {
|
||||
startedAt: number
|
||||
finishedAt?: number
|
||||
}
|
||||
/** 控制室卡片数据(index.tsx 组装;纯读镜像,零轮询零 Token) */
|
||||
export type ConsoleCardData = {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
/** 三态 + 空闲:need(待决黄) > done(完成绿) > busy(工作中蓝) > idle */
|
||||
status: 'idle' | 'busy' | 'need' | 'done'
|
||||
/** 正在运行时的本轮已用毫秒;非运行态 null */
|
||||
runtimeMs: number | null
|
||||
/** 子代理数量 */
|
||||
kids: number
|
||||
/** 最近一条消息预览(单行文本,可能为空) */
|
||||
preview: string
|
||||
/** 是否绑定对话 */
|
||||
bound: boolean
|
||||
/** 是否为「工作台」自己(卡片点击无操作) */
|
||||
self: boolean
|
||||
}
|
||||
|
||||
type SplitEnv = {
|
||||
getScope: () => SplitScope | null
|
||||
getJobs: () => SplitJob[]
|
||||
@@ -199,6 +220,15 @@ type SplitEnv = {
|
||||
submit: (projectId: string, projectName: string, requirement: string) => Promise<void>
|
||||
sendToSession: (sessionId: string, projectName: string, requirement: string) => Promise<void>
|
||||
}
|
||||
/** 控制室:卡片数据订阅 + 打开项目 / 跳绑定对话 + 主题 */
|
||||
console?: {
|
||||
subscribe: (fn: () => void) => () => void
|
||||
getCards: () => ConsoleCardData[]
|
||||
onOpen: (id: string) => void
|
||||
onJump: (id: string) => void
|
||||
getTheme: () => 'dark' | 'light' | 'system'
|
||||
setTheme: (th: 'dark' | 'light' | 'system') => void
|
||||
}
|
||||
}
|
||||
let splitEnv: SplitEnv | null = null
|
||||
export function setSplitEnv(env: SplitEnv | null) {
|
||||
@@ -919,6 +949,118 @@ function AnimPane(props: { reloadKey: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 控制室面板:项目卡片网格(每行 3 张、超出换行);数据由工作台组装推送(纯读镜像)。
|
||||
* 主题:dark/light 直接生效;system = 跟随宿主 html 的 color-scheme(DSH 深色/白色/跟随系统都会反映到它) */
|
||||
function ConsolePane() {
|
||||
const [, setTick] = useState(0)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [themeMode, setThemeMode] = useState<'dark' | 'light' | 'system'>(() => splitEnv?.console?.getTheme?.() ?? 'system')
|
||||
const [sysDark, setSysDark] = useState(() => {
|
||||
try { return window.matchMedia('(prefers-color-scheme: dark)').matches } catch { return true }
|
||||
})
|
||||
useEffect(() => {
|
||||
const env = splitEnv?.console
|
||||
if (!env) return
|
||||
const bump = () => { setTick((t) => t + 1); setThemeMode(env.getTheme?.() ?? 'system') }
|
||||
const un = env.subscribe(bump)
|
||||
bump()
|
||||
return un
|
||||
}, [])
|
||||
// 跟随系统:监听 OS 深浅切换(仅 themeMode==='system' 时影响渲染)
|
||||
useEffect(() => {
|
||||
let mq: MediaQueryList | null = null
|
||||
try { mq = window.matchMedia('(prefers-color-scheme: dark)') } catch {}
|
||||
if (!mq) return
|
||||
const f = (e: any) => setSysDark(!!e?.matches)
|
||||
try { mq.addEventListener('change', f) } catch { try { (mq as any).addListener(f) } catch {} }
|
||||
return () => { try { mq.removeEventListener('change', f) } catch { try { (mq as any).removeListener(f) } catch {} } }
|
||||
}, [])
|
||||
// 运行时长的分钟级刷新:每秒重渲染一次(仅控制室开着时存在)
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(iv)
|
||||
}, [])
|
||||
void now
|
||||
const env = splitEnv?.console
|
||||
const cards = env ? env.getCards() : []
|
||||
const resolvedTheme: 'dark' | 'light' = (() => {
|
||||
if (themeMode !== 'system') return themeMode
|
||||
try {
|
||||
const cs = getComputedStyle(document.documentElement).colorScheme || ''
|
||||
if (cs.includes('dark') && !cs.includes('light')) return 'dark'
|
||||
if (cs.includes('light') && !cs.includes('dark')) return 'light'
|
||||
} catch {}
|
||||
return sysDark ? 'dark' : 'light'
|
||||
})()
|
||||
const setTheme = (th: 'dark' | 'light' | 'system') => { setThemeMode(th); env?.setTheme?.(th) }
|
||||
const fmtDur = (ms: number) => {
|
||||
const s = Math.max(0, Math.floor(ms / 1000))
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const ss = s % 60
|
||||
return h > 0 ? h + ':' + String(m).padStart(2, '0') + ':' + String(ss).padStart(2, '0') : m + ':' + String(ss).padStart(2, '0')
|
||||
}
|
||||
const statusLabel: Record<string, string> = { idle: T('console.idle'), busy: T('console.busy'), need: T('console.need'), done: T('console.done') }
|
||||
const themeOpts: { mode: 'dark' | 'light' | 'system'; key: string }[] = [
|
||||
{ mode: 'dark', key: 'console.themeDark' },
|
||||
{ mode: 'light', key: 'console.themeLight' },
|
||||
{ mode: 'system', key: 'console.themeSystem' },
|
||||
]
|
||||
return (
|
||||
<div className="dsh-wt_console" data-wt-theme={resolvedTheme}>
|
||||
<div className="dsh-wt_consoleHead">
|
||||
<span className="dsh-wt_consoleTitle">🖥️ {T('console.title')}</span>
|
||||
<div className="dsh-wt_consoleTheme" role="group" aria-label={T('console.themeLabel')}>
|
||||
{themeOpts.map((o) => (
|
||||
<button
|
||||
key={o.mode}
|
||||
type="button"
|
||||
className={'dsh-wt_consoleThemeBtn' + (themeMode === o.mode ? ' dsh-wt_consoleThemeBtnOn' : '')}
|
||||
onClick={() => setTheme(o.mode)}
|
||||
>{T(o.key)}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dsh-wt_consoleGrid">
|
||||
{cards.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
role={c.self ? undefined : 'button'}
|
||||
tabIndex={c.self ? -1 : 0}
|
||||
className={'dsh-wt_consoleCard' + (c.self ? ' dsh-wt_consoleCardSelf' : '')}
|
||||
title={c.name}
|
||||
onClick={() => { if (!c.self && env) env.onOpen(c.id) }}
|
||||
>
|
||||
<div className="dsh-wt_consoleCardHead">
|
||||
<span className="dsh-wt_consoleIcon" aria-hidden>{c.icon}</span>
|
||||
<span className="dsh-wt_consoleName">{c.name}</span>
|
||||
<span className={'dsh-wt_consoleDot dsh-wt_consoleDot-' + c.status} aria-hidden />
|
||||
</div>
|
||||
<div className="dsh-wt_consoleBadges">
|
||||
<span className={'dsh-wt_consoleBadge dsh-wt_consoleBadge-' + c.status}>{statusLabel[c.status]}</span>
|
||||
{c.runtimeMs != null && <span className="dsh-wt_consoleBadge">⏱ {fmtDur(c.runtimeMs)}</span>}
|
||||
<span className="dsh-wt_consoleBadge">{T('console.kids')} {c.kids}</span>
|
||||
</div>
|
||||
<div className={'dsh-wt_consolePreview' + (c.preview ? '' : ' dsh-wt_consolePreviewNone')} title={c.preview}>
|
||||
{c.preview || (c.bound ? T('console.noPreview') : T('console.unboundShort'))}
|
||||
</div>
|
||||
{c.bound && !c.self && (
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-wt_consoleJump"
|
||||
title={T('console.jump')}
|
||||
aria-label={T('console.jump')}
|
||||
onClick={(e) => { e.stopPropagation(); env?.onJump(c.id) }}
|
||||
>💬</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{cards.length === 0 && <div className="dsh-wt_consoleEmpty">{T('console.empty')}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 文件夹图标(重绘 SVG,与 better-sidebar 同款风格) */
|
||||
function FolderIcon() {
|
||||
return (
|
||||
@@ -1542,6 +1684,7 @@ function PaneTabBody(props: { tab: PaneTab; row: PaneRow; index: number; paneTit
|
||||
}
|
||||
if (content.type === 'browser') return <BrowserPane reloadKey={props.reloadKey} />
|
||||
if (content.type === 'anim') return <AnimPane reloadKey={props.reloadKey} />
|
||||
if (content.type === 'console') return <ConsolePane />
|
||||
if (content.type === 'explorer') return <ExplorerPane row={props.row} index={props.index} />
|
||||
if (content.type === 'scm') return <GitPane />
|
||||
if (content.type === 'tasks') return <JobsPane />
|
||||
|
||||
@@ -380,4 +380,48 @@ export const css = xtermCss + '\n' + [
|
||||
'.dsh-wt_treeIcon{flex:none}',
|
||||
'.dsh-wt_treeName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||
'.dsh-wt_subSection{flex:none;padding:6px 8px 2px;font-size:10px;font-weight:600;letter-spacing:.05em;color:var(--dsw-alias-label-tertiary,#6e7683)}',
|
||||
// 控制室面板:项目卡片网格(每行 3 张、超出换行;DSH 简洁 + 苹果式卡片)。
|
||||
// 主题变量自带作用域:dark 缺省;data-wt-theme=light 换浅色;system 由面板读宿主 color-scheme 落成 dark/light。
|
||||
'.dsh-wt_console{--wt-bg:#10141b;--wt-card:#171c25;--wt-cardHover:#1d232e;--wt-border:#2a3140;--wt-borderHover:#39445a;--wt-text:#e6e8eb;--wt-text2:#9aa4b2;--wt-text3:#6e7683;--wt-chip:rgba(255,255,255,.06);--wt-shadow:0 6px 20px rgba(0,0,0,.35);flex:1;min-height:0;overflow:auto;padding:14px;background:var(--wt-bg)}',
|
||||
'.dsh-wt_console[data-wt-theme=light]{--wt-bg:#f2f4f7;--wt-card:#ffffff;--wt-cardHover:#f7f9fb;--wt-border:#d8dee6;--wt-borderHover:#b9c4d2;--wt-text:#1c2128;--wt-text2:#57606a;--wt-text3:#8b949e;--wt-chip:rgba(27,31,36,.07);--wt-shadow:0 6px 20px rgba(31,41,55,.10)}',
|
||||
'.dsh-wt_consoleHead{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}',
|
||||
'.dsh-wt_consoleTitle{font-size:14px;font-weight:700;line-height:20px;color:var(--wt-text)}',
|
||||
'.dsh-wt_consoleTheme{display:inline-flex;align-items:center;padding:2px;border:1px solid var(--wt-border);border-radius:999px;background:var(--wt-card)}',
|
||||
'.dsh-wt_consoleThemeBtn{flex:none;padding:2px 10px;border:none;border-radius:999px;background:transparent;color:var(--wt-text2);font:inherit;font-size:11px;line-height:18px;cursor:pointer}',
|
||||
'.dsh-wt_consoleThemeBtnOn{background:var(--wt-chip);color:var(--wt-text);font-weight:600}',
|
||||
'.dsh-wt_consoleGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;align-content:start}',
|
||||
'.dsh-wt_consoleCard{position:relative;display:flex;flex-direction:column;gap:8px;min-height:96px;padding:12px 14px;background:var(--wt-card);border:1px solid var(--wt-border);border-radius:12px;cursor:pointer;transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}',
|
||||
'.dsh-wt_consoleCard:hover{transform:translateY(-1px);border-color:var(--wt-borderHover);box-shadow:var(--wt-shadow)}',
|
||||
'.dsh-wt_consoleCardSelf{cursor:default;opacity:.96}',
|
||||
'.dsh-wt_consoleCardHead{display:flex;align-items:center;gap:8px;min-width:0}',
|
||||
'.dsh-wt_consoleIcon{flex:none;font-size:20px;line-height:1}',
|
||||
'.dsh-wt_consoleName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;line-height:20px;color:var(--wt-text)}',
|
||||
'.dsh-wt_consoleDot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--wt-text3)}',
|
||||
'.dsh-wt_consoleDot-busy{background:var(--dsw-alias-state-accent-primary,#4f8ef7);box-shadow:0 0 0 3px rgba(79,142,247,.15)}',
|
||||
'.dsh-wt_consoleDot-need{background:var(--dsw-alias-state-warning,#d29922);box-shadow:0 0 0 3px rgba(210,153,34,.15)}',
|
||||
'.dsh-wt_consoleDot-done{background:var(--dsw-alias-state-success,#3fb950);box-shadow:0 0 0 3px rgba(63,185,80,.15)}',
|
||||
'.dsh-wt_consoleBadges{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
|
||||
'.dsh-wt_consoleBadge{flex:none;padding:1px 7px;border-radius:999px;font-size:10.5px;line-height:16px;color:var(--wt-text2);background:var(--wt-chip)}',
|
||||
'.dsh-wt_consoleBadge-busy{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
|
||||
'.dsh-wt_consoleBadge-need{color:var(--dsw-alias-state-warning,#d29922)}',
|
||||
'.dsh-wt_consoleBadge-done{color:var(--dsw-alias-state-success,#3fb950)}',
|
||||
'.dsh-wt_consolePreview{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:16px;color:var(--wt-text2)}',
|
||||
'.dsh-wt_consolePreviewNone{color:var(--wt-text3);font-style:italic}',
|
||||
'.dsh-wt_consoleJump{position:absolute;top:10px;right:10px;width:22px;height:22px;padding:0;border:none;border-radius:6px;background:transparent;color:var(--wt-text3);font-size:12px;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s ease}',
|
||||
'.dsh-wt_consoleCard:hover .dsh-wt_consoleJump{opacity:1}',
|
||||
'.dsh-wt_consoleJump:hover{color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--wt-chip)}',
|
||||
'.dsh-wt_consoleEmpty{padding:40px 0;text-align:center;font-size:12px;color:var(--wt-text3)}',
|
||||
// 控制室强制绑定弹窗:左加入现有 / 右新建对话
|
||||
'.dsh-wt_consoleBindCols{display:flex;gap:12px}',
|
||||
'.dsh-wt_consoleBindCol{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}',
|
||||
'.dsh-wt_consoleBindColNew{border-left:1px solid var(--dsw-alias-border-l1,#262b36);padding-left:12px}',
|
||||
'.dsh-wt_consoleBindLabel{font-size:12px;font-weight:600;line-height:18px;color:var(--dsw-alias-label-primary,#e6e8eb)}',
|
||||
'.dsh-wt_consoleBindList{max-height:300px;overflow:auto;position:static}',
|
||||
'.dsh-wt_consoleBindEmpty{padding:20px 0;text-align:center;font-size:11px;color:var(--dsw-alias-label-tertiary,#6e7683)}',
|
||||
'.dsh-wt_consoleSelect,.dsh-wt_consoleInput{width:100%;box-sizing:border-box;padding:6px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11.5px;line-height:16px;outline:none}',
|
||||
'.dsh-wt_consoleSelect:focus,.dsh-wt_consoleInput:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
|
||||
'.dsh-wt_consoleErr{margin:0;font-size:11px;line-height:16px;color:var(--dsw-alias-state-danger,#f85149)}',
|
||||
'.dsh-wt_consoleCreateBtn{margin-top:2px;padding:7px 10px;border:1px solid var(--dsw-alias-state-accent-primary,#4f8ef7);border-radius:8px;background:transparent;color:var(--dsw-alias-state-accent-primary,#4f8ef7);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
|
||||
'.dsh-wt_consoleCreateBtn:hover:not(:disabled){background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
|
||||
'.dsh-wt_consoleCreateBtn:disabled{opacity:.55;cursor:default}',
|
||||
].join('\n')
|
||||
|
||||
Reference in New Issue
Block a user