feat: add deepseek-harness workbench plugin and console diagnosis API

- console: GET /api/workflows/{id}, /runs/{run_id}, /runs/{run_id}/diagnosis
  (journal trace + sanitized bounded log tails, workflow/run pairing enforced)
- workbench/: dsh 宿主插件(7 个工作流工具、中文系统提示词、失败监控器、
  本机回环桥接服务)+ 侧边栏面板客户端包(sidebar.footer.action 与
  shell.overlay 追加插槽)+ 降级独立面板 + 一键启动脚本
- adapters/browser: 收敛 looks_like_login_url 到共享层,修复
  jd_main_image_collector 对 gyxx_flow.accounts 的越层导入
- tests: 新端点覆盖;replay_policy 断言对齐已迁移的 catalog(repeatable)
This commit is contained in:
wangyunlong committed 2026-09-04 11:10:16 +08:00
1 parent b124d757b0
commit 01218b2907
26 files changed
+4347 -8

No files matched your search

+355
View File
@@ -0,0 +1,355 @@
/** 工作流面板:侧边抽屉,列出全部工作流,选中后可提问 / 诊断 / 修复 / 启停。 */
import { useSyncExternalStore, useEffect, useCallback } from 'react'
import { getState, subscribe, update, pushToast } from './store.js'
import { api } from './api.js'
function useWorkbench() {
return useSyncExternalStore(subscribe, getState)
}
const STATUS_LABELS = {
success: '成功',
failed: '失败',
running: '运行中',
cancelled: '已停止',
none: '未运行',
}
function statusOf(workflow) {
if (workflow.active_run) return 'running'
return workflow.last_run?.status ?? 'none'
}
function formatTime(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
const pad = (n) => String(n).padStart(2, '0')
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function formatDuration(seconds) {
if (seconds == null) return ''
if (seconds < 60) return `${seconds}s`
return `${Math.floor(seconds / 60)}m${seconds % 60 ? `${seconds % 60}s` : ''}`
}
export async function refreshWorkflows() {
update({ loading: true, error: null })
try {
const payload = await api.listWorkflows()
update({
loading: false,
workflows: payload.workflows ?? [],
summary: payload.summary ?? null,
generatedAt: payload.generated_at ?? null,
})
} catch (error) {
update({ loading: false, error: error.message })
}
}
async function selectWorkflow(workflowId) {
update({ selectedId: workflowId, detailLoading: true, diagnosis: null, runs: [] })
try {
const [detail, runs] = await Promise.all([
api.workflowDetail(workflowId).catch(() => null),
api.workflowRuns(workflowId, 6).catch(() => ({ runs: [] })),
])
update({ detailLoading: false, detail, runs: runs.runs ?? [] })
} catch (error) {
update({ detailLoading: false, error: error.message })
}
}
async function loadDiagnosis(workflowId, runId) {
update({ diagnosisLoading: true })
try {
const payload = await api.diagnosis(workflowId, runId)
update({ diagnosisLoading: false, diagnosis: payload })
} catch (error) {
update({ diagnosisLoading: false })
pushToast({ kind: 'error', text: `诊断数据加载失败:${error.message}` })
}
}
async function doAsk(action) {
const s = getState()
if (!s.selectedId) return
const question = s.question.trim()
try {
const payload = await api.ask({
workflow_id: s.selectedId,
action,
question,
})
const label = { ask: '提问', diagnose: '诊断', repair: '修复' }[action] ?? '会话'
pushToast({
kind: 'ok',
text: `已创建${label}会话(${String(payload.session_id).slice(0, 18)}…),请在左侧会话列表中查看`,
})
update({ question: '' })
} catch (error) {
pushToast({ kind: 'error', text: `创建会话失败:${error.message}` })
}
}
async function doTrigger(execute) {
const s = getState()
if (!s.selectedId) return
if (execute) {
const ok = window.confirm(
`正式执行 ${s.selectedId}(业务日期 ${s.businessDate})会对外部系统产生真实写入。确认继续?`,
)
if (!ok) return
}
try {
await api.trigger({
workflow_id: s.selectedId,
business_date: s.businessDate,
execute,
confirmed: execute,
})
pushToast({ kind: 'ok', text: execute ? '已发起正式执行' : '已发起试运行(无外部副作用)' })
setTimeout(refreshWorkflows, 1500)
} catch (error) {
pushToast({ kind: 'error', text: `启动失败:${error.message}` })
}
}
async function doCancel() {
const s = getState()
const active = s.detail?.workflow?.active_run
if (!s.selectedId) return
try {
if (active?.operation_id) {
await api.cancel({ workflow_id: s.selectedId, operation_id: active.operation_id })
} else {
await api.cancel({ workflow_id: s.selectedId, scheduled: true })
}
pushToast({ kind: 'ok', text: '已发送停止指令' })
setTimeout(refreshWorkflows, 1500)
} catch (error) {
pushToast({ kind: 'error', text: `停止失败:${error.message}` })
}
}
function SummaryPills({ summary, workflows }) {
const failed = workflows.filter((w) => statusOf(w) === 'failed').length
const running = workflows.filter((w) => w.active_run).length
return (
<div className="gyxxwb-pills">
<span className="gyxxwb-pill">共 {summary?.total ?? workflows.length}</span>
{running > 0 && <span className="gyxxwb-pill gyxxwb-pill-running">运行中 {running}</span>}
{failed > 0 && <span className="gyxxwb-pill gyxxwb-pill-failed">失败 {failed}</span>}
</div>
)
}
function WorkflowRow({ workflow, selected, onSelect }) {
const status = statusOf(workflow)
return (
<button
type="button"
className={`gyxxwb-row ${selected ? 'gyxxwb-row-selected' : ''}`}
onClick={() => onSelect(workflow.id)}
>
<span className={`gyxxwb-dot gyxxwb-dot-${status}`} />
<span className="gyxxwb-row-main">
<span className="gyxxwb-row-name">{workflow.name}</span>
<span className="gyxxwb-row-id">{workflow.id}</span>
</span>
<span className="gyxxwb-row-meta">
{workflow.last_run ? formatTime(workflow.last_run.ended_at ?? workflow.last_run.started_at) : '—'}
</span>
</button>
)
}
function RunList({ runs, onDiagnose }) {
if (!runs.length) return <div className="gyxxwb-empty">暂无运行记录</div>
return (
<div className="gyxxwb-runs">
{runs.map((run) => (
<button
type="button"
key={run.run_id}
className="gyxxwb-run"
onClick={() => onDiagnose(run.run_id)}
title="点击查看诊断详情"
>
<span className={`gyxxwb-dot gyxxwb-dot-${run.status}`} />
<span>{run.business_date}</span>
<span className="gyxxwb-run-mode">{run.mode === 'execute' ? '正式' : '试运行'}</span>
<span>{formatDuration(run.duration_seconds)}</span>
{run.error && <span className="gyxxwb-run-error">{run.error.slice(0, 60)}</span>}
</button>
))}
</div>
)
}
function DiagnosisView({ diagnosis, loading }) {
if (loading) return <div className="gyxxwb-empty">正在加载诊断数据…</div>
if (!diagnosis) return null
if (diagnosis.message) return <div className="gyxxwb-empty">{diagnosis.message}</div>
const run = diagnosis.run ?? {}
const steps = Array.isArray(run.steps) ? run.steps : []
const logs = Array.isArray(diagnosis.logs) ? diagnosis.logs : []
return (
<div className="gyxxwb-diagnosis">
<div className="gyxxwb-diagnosis-head">
诊断:{run.run_id}({run.status})
</div>
{steps
.filter((step) => step.status === 'failed')
.map((step) => (
<div key={step.id} className="gyxxwb-step-fail">
<div className="gyxxwb-step-id">
✗ {step.id}(退出码 {step.exit_code ?? '—'})
</div>
{step.error && <pre className="gyxxwb-pre">{step.error}</pre>}
</div>
))}
{logs.map((log) => (
<div key={log.path} className="gyxxwb-log">
<div className="gyxxwb-log-path">{log.path}</div>
<pre className="gyxxwb-pre">{log.tail}</pre>
</div>
))}
{!steps.some((s) => s.status === 'failed') && !logs.length && (
<div className="gyxxwb-empty">该运行没有失败步骤或日志</div>
)}
</div>
)
}
export function WorkflowPanel() {
const s = useWorkbench()
useEffect(() => {
if (!s.open) return undefined
refreshWorkflows()
const timer = setInterval(refreshWorkflows, 10_000)
return () => clearInterval(timer)
}, [s.open])
const onSelect = useCallback((workflowId) => {
selectWorkflow(workflowId)
}, [])
const groups = []
const byModule = new Map()
for (const workflow of s.workflows) {
const key = workflow.module_label ?? workflow.module
if (!byModule.has(key)) byModule.set(key, [])
byModule.get(key).push(workflow)
}
for (const [label, items] of byModule) groups.push({ label, items })
const selected = s.workflows.find((w) => w.id === s.selectedId) ?? null
return (
<div className={`gyxxwb-panel ${s.open ? 'gyxxwb-panel-open' : ''}`}>
<div className="gyxxwb-panel-head">
<span className="gyxxwb-title">GYXX 工作流</span>
<SummaryPills summary={s.summary} workflows={s.workflows} />
<button type="button" className="gyxxwb-icon-btn" title="刷新" onClick={refreshWorkflows}>
⟳
</button>
<button
type="button"
className="gyxxwb-icon-btn"
title="关闭"
onClick={() => update({ open: false })}
>
✕
</button>
</div>
{s.error && (
<div className="gyxxwb-error">
无法连接工作台桥接服务:{s.error}
<br />
请确认 dsh 已通过 workbench/cordis.yml 启动。
</div>
)}
<div className="gyxxwb-panel-body">
<div className="gyxxwb-list">
{groups.map((group) => (
<div key={group.label} className="gyxxwb-group">
<div className="gyxxwb-group-label">{group.label}</div>
{group.items.map((workflow) => (
<WorkflowRow
key={workflow.id}
workflow={workflow}
selected={workflow.id === s.selectedId}
onSelect={onSelect}
/>
))}
</div>
))}
{!s.loading && !s.workflows.length && !s.error && (
<div className="gyxxwb-empty">未发现工作流</div>
)}
</div>
{selected && (
<div className="gyxxwb-detail">
<div className="gyxxwb-detail-title">
{selected.name}
<span className={`gyxxwb-status gyxxwb-status-${statusOf(selected)}`}>
{STATUS_LABELS[statusOf(selected)]}
</span>
</div>
<div className="gyxxwb-detail-sub">
{selected.id} · 调度{' '}
{selected.schedule
? `${selected.schedule.enabled ? '启用' : '停用'} ${
Array.isArray(selected.schedule.at)
? selected.schedule.at.join('/')
: (selected.schedule.at ?? '')
}`
: '无'}
{selected.next_run_at ? ` · 下次 ${formatTime(selected.next_run_at)}` : ''}
</div>
<div className="gyxxwb-actions">
<button type="button" onClick={() => doAsk('diagnose')}>诊断</button>
<button type="button" onClick={() => doAsk('repair')}>修复</button>
<button type="button" onClick={() => doTrigger(false)}>试运行</button>
<button type="button" className="gyxxwb-danger" onClick={() => doTrigger(true)}>
正式运行
</button>
{selected.active_run && (
<button type="button" className="gyxxwb-danger" onClick={doCancel}>
停止
</button>
)}
</div>
<div className="gyxxwb-ask">
<input
value={s.question}
placeholder="就这个工作流提问,例如:昨天为什么失败?"
onChange={(event) => update({ question: event.target.value })}
onKeyDown={(event) => {
if (event.key === 'Enter') doAsk('ask')
}}
/>
<button type="button" onClick={() => doAsk('ask')}>提问</button>
</div>
<div className="gyxxwb-date-row">
业务日期{' '}
<input
value={s.businessDate}
onChange={(event) => update({ businessDate: event.target.value })}
pattern="\d{4}-\d{2}-\d{2}"
/>
</div>
<div className="gyxxwb-section-label">最近运行</div>
<RunList runs={s.runs} onDiagnose={(runId) => loadDiagnosis(selected.id, runId)} />
<DiagnosisView diagnosis={s.diagnosis} loading={s.diagnosisLoading} />
</div>
)}
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
/** 失败告警浮层:轮询桥接告警,新失败弹出 toast,可一键发起诊断会话。 */
import { useEffect } from 'react'
import { useSyncExternalStore } from 'react'
import { getState, subscribe, update, dismissToast, pushToast } from './store.js'
import { api } from './api.js'
function useWorkbench() {
return useSyncExternalStore(subscribe, getState)
}
export function Toasts() {
const s = useWorkbench()
useEffect(() => {
let stopped = false
let timer = null
const poll = async () => {
try {
const payload = await api.alerts()
const fresh = (payload.alerts ?? []).filter(
(alert) => !alert.seen && !getState().seenAlertIds[alert.id],
)
if (fresh.length) {
const seen = { ...getState().seenAlertIds }
for (const alert of fresh) {
seen[alert.id] = true
pushToast({ kind: 'failed', text: `工作流「${alert.name}」运行失败`, alert })
}
update({ seenAlertIds: seen })
}
} catch {
// 桥接离线时静默
}
if (!stopped) timer = setTimeout(poll, 30_000)
}
timer = setTimeout(poll, 8_000)
return () => {
stopped = true
if (timer) clearTimeout(timer)
}
}, [])
if (!s.toasts.length) return null
return (
<div className="gyxxwb-toasts">
{s.toasts.map((toast) => (
<div key={toast.id} className={`gyxxwb-toast gyxxwb-toast-${toast.kind}`}>
<span className="gyxxwb-toast-text">{toast.text}</span>
{toast.alert && (
<button
type="button"
onClick={async () => {
try {
await api.ask({ workflow_id: toast.alert.workflow_id, action: 'diagnose' })
await api.markAlertSeen(toast.alert.id).catch(() => {})
pushToast({ kind: 'ok', text: '已创建诊断会话,请在左侧会话列表查看' })
} catch (error) {
pushToast({ kind: 'error', text: `创建会话失败:${error.message}` })
}
dismissToast(toast.id)
}}
>
立即诊断
</button>
)}
<button type="button" title="关闭" onClick={() => dismissToast(toast.id)}>
✕
</button>
</div>
))}
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
/** 工作台桥接服务客户端:所有数据经由 gyxx-workbench 宿主插件的本机回环桥接。 */
export function bridgeBase() {
if (typeof window !== 'undefined') {
if (window.__GYXX_WB_BRIDGE__) return window.__GYXX_WB_BRIDGE__
try {
const saved = window.localStorage?.getItem('gyxx.wb.bridge')
if (saved) return saved
} catch {
// localStorage 不可用时使用默认值
}
}
return 'http://127.0.0.1:8790'
}
async function request(method, path, body) {
const response = await fetch(`${bridgeBase()}${path}`, {
method,
headers: {
accept: 'application/json',
...(body !== undefined
? { 'content-type': 'application/json', 'x-gyxx-workbench': '1' }
: {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
let payload
try {
payload = await response.json()
} catch {
payload = { ok: false, error: `桥接服务返回了非 JSON 内容(HTTP ${response.status})` }
}
if (!response.ok) {
throw new Error(payload?.error ?? `桥接服务错误(HTTP ${response.status})`)
}
return payload
}
export const api = {
listWorkflows: () => request('GET', '/bridge/workflows'),
workflowDetail: (workflowId) =>
request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}`),
workflowRuns: (workflowId, limit = 6) =>
request('GET', `/bridge/workflows/${encodeURIComponent(workflowId)}/runs?limit=${limit}`),
diagnosis: (workflowId, runId) =>
request(
'GET',
`/bridge/workflows/${encodeURIComponent(workflowId)}/diagnosis${runId ? `?run_id=${encodeURIComponent(runId)}` : ''}`,
),
alerts: () => request('GET', '/bridge/alerts'),
markAlertSeen: (id) => request('POST', '/bridge/alerts/seen', { id }),
trigger: (payload) => request('POST', '/bridge/trigger', payload),
cancel: (payload) => request('POST', '/bridge/cancel', payload),
ask: (payload) => request('POST', '/bridge/ask', payload),
}
+71
View File
@@ -0,0 +1,71 @@
/**
* GYXX 工作台客户端插件入口。
* 由 esbuild 打包为 CJS 并通过 window.__ModuleLoader__ 注册(见 scripts/build-client.mjs)。
* 运行时只依赖 dsh 平台共享模块表中的 react。
*/
import { createElement, useSyncExternalStore } from 'react'
import { WorkflowPanel, refreshWorkflows } from './Panel.jsx'
import { Toasts } from './Toasts.jsx'
import { getState, subscribe, update } from './store.js'
import stylesText from './styles.css'
export const inject = ['slots']
function useWorkbench() {
return useSyncExternalStore(subscribe, getState)
}
function injectStyles() {
if (typeof document === 'undefined') return
if (document.getElementById('gyxxwb-styles')) return
const style = document.createElement('style')
style.id = 'gyxxwb-styles'
style.textContent = stylesText
document.head.appendChild(style)
}
/** 侧边栏底部动作:工作流面板开关 + 聚合状态点。 */
function WorkbenchFooterAction() {
const s = useWorkbench()
const failed = s.workflows.filter((w) => !w.active_run && w.last_run?.status === 'failed').length
const running = s.workflows.filter((w) => w.active_run).length
const tone = failed > 0 ? 'failed' : running > 0 ? 'running' : 'success'
return createElement(
'button',
{
type: 'button',
className: `gyxxwb-footer-btn${s.open ? ' gyxxwb-footer-btn-active' : ''}`,
title: 'GYXX 工作流工作台',
onClick: () => {
const next = !getState().open
update({ open: next })
if (next) refreshWorkflows()
},
},
createElement('span', { className: `gyxxwb-dot gyxxwb-dot-${tone}` }),
createElement('span', null, '工作流'),
)
}
/** 覆盖层根:左侧抽屉面板 + 失败 toast。 */
function WorkbenchOverlay() {
return createElement(
'div',
{ className: 'gyxxwb-overlay' },
createElement(WorkflowPanel, null),
createElement(Toasts, null),
)
}
export function apply(ctx) {
injectStyles()
ctx.effect(
() => ctx.slots.register({ name: 'sidebar.footer.action' }, WorkbenchFooterAction),
'gyxx-workbench: footer action',
)
ctx.effect(
() => ctx.slots.register({ name: 'shell.overlay' }, WorkbenchOverlay),
'gyxx-workbench: overlay',
)
}
+57
View File
@@ -0,0 +1,57 @@
/**
* GYXX 工作台面板 —— 极简外部存储(避免向客户端包引入额外依赖)。
* React 18 的 useSyncExternalStore 直接订阅。
*/
const listeners = new Set()
let state = {
open: false,
loading: false,
error: null,
generatedAt: null,
summary: null,
workflows: [],
selectedId: null,
detail: null,
detailLoading: false,
runs: [],
diagnosis: null,
diagnosisLoading: false,
question: '',
businessDate: defaultBusinessDate(),
alerts: [],
seenAlertIds: {},
toasts: [],
}
export function defaultBusinessDate() {
const now = new Date()
now.setDate(now.getDate() - 1)
const pad = (value) => String(value).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
export function getState() {
return state
}
export function update(patch) {
state = { ...state, ...patch }
for (const listener of listeners) listener()
}
export function subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function pushToast(toast) {
const id = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
update({ toasts: [...state.toasts, { id, ...toast }] })
return id
}
export function dismissToast(id) {
update({ toasts: state.toasts.filter((toast) => toast.id !== id) })
}
+471
View File
@@ -0,0 +1,471 @@
/* GYXX 工作台面板样式 —— 以文本形式内联进客户端包,运行时注入 <style>。 */
.gyxxwb-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 70;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
.gyxxwb-panel {
position: absolute;
top: 0;
bottom: 0;
left: 264px;
width: 380px;
display: none;
flex-direction: column;
background: #ffffff;
border-right: 1px solid #e4e4e7;
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);
pointer-events: auto;
color: #18181b;
}
.gyxxwb-panel-open {
display: flex;
}
.gyxxwb-panel-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #e4e4e7;
}
.gyxxwb-title {
font-weight: 600;
font-size: 14px;
}
.gyxxwb-pills {
display: flex;
gap: 4px;
flex: 1;
}
.gyxxwb-pill {
font-size: 11px;
padding: 1px 8px;
border-radius: 999px;
background: #f4f4f5;
color: #52525b;
}
.gyxxwb-pill-failed {
background: #fef2f2;
color: #b91c1c;
}
.gyxxwb-pill-running {
background: #eff6ff;
color: #1d4ed8;
}
.gyxxwb-icon-btn {
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: #71717a;
padding: 4px 6px;
border-radius: 6px;
}
.gyxxwb-icon-btn:hover {
background: #f4f4f5;
}
.gyxxwb-error {
margin: 10px 12px;
padding: 10px;
border-radius: 8px;
background: #fef2f2;
color: #b91c1c;
font-size: 12px;
line-height: 1.6;
}
.gyxxwb-panel-body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.gyxxwb-group-label {
padding: 10px 12px 4px;
font-size: 12px;
color: #71717a;
}
.gyxxwb-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
}
.gyxxwb-row:hover {
background: #f4f4f5;
}
.gyxxwb-row-selected {
background: #eef2ff;
}
.gyxxwb-row-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.gyxxwb-row-name {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gyxxwb-row-id {
font-size: 11px;
color: #a1a1aa;
}
.gyxxwb-row-meta {
font-size: 11px;
color: #71717a;
}
.gyxxwb-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #d4d4d8;
flex: none;
}
.gyxxwb-dot-success {
background: #16a34a;
}
.gyxxwb-dot-failed {
background: #dc2626;
}
.gyxxwb-dot-running {
background: #2563eb;
animation: gyxxwb-pulse 1.2s ease-in-out infinite;
}
.gyxxwb-dot-cancelled,
.gyxxwb-dot-none {
background: #a1a1aa;
}
@keyframes gyxxwb-pulse {
50% {
opacity: 0.35;
}
}
.gyxxwb-detail {
border-top: 1px solid #e4e4e7;
padding: 12px;
background: #fafafa;
}
.gyxxwb-detail-title {
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.gyxxwb-status {
font-size: 11px;
padding: 1px 8px;
border-radius: 999px;
background: #f4f4f5;
}
.gyxxwb-status-failed {
background: #fef2f2;
color: #b91c1c;
}
.gyxxwb-status-running {
background: #eff6ff;
color: #1d4ed8;
}
.gyxxwb-status-success {
background: #f0fdf4;
color: #15803d;
}
.gyxxwb-detail-sub {
font-size: 11px;
color: #71717a;
margin: 4px 0 10px;
}
.gyxxwb-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.gyxxwb-actions button,
.gyxxwb-ask button {
border: 1px solid #e4e4e7;
background: #ffffff;
border-radius: 6px;
font-size: 12px;
padding: 4px 10px;
cursor: pointer;
}
.gyxxwb-actions button:hover,
.gyxxwb-ask button:hover {
background: #f4f4f5;
}
.gyxxwb-actions .gyxxwb-danger {
border-color: #fecaca;
color: #b91c1c;
}
.gyxxwb-ask {
display: flex;
gap: 6px;
margin-top: 8px;
}
.gyxxwb-ask input {
flex: 1;
border: 1px solid #e4e4e7;
border-radius: 6px;
padding: 5px 8px;
font-size: 12px;
}
.gyxxwb-date-row {
margin-top: 8px;
font-size: 11px;
color: #71717a;
display: flex;
align-items: center;
gap: 6px;
}
.gyxxwb-date-row input {
border: 1px solid #e4e4e7;
border-radius: 6px;
padding: 3px 6px;
font-size: 11px;
width: 110px;
}
.gyxxwb-section-label {
margin: 12px 0 4px;
font-size: 12px;
color: #71717a;
}
.gyxxwb-run {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
text-align: left;
border-radius: 6px;
}
.gyxxwb-run:hover {
background: #f4f4f5;
}
.gyxxwb-run-mode {
color: #a1a1aa;
font-size: 11px;
}
.gyxxwb-run-error {
color: #b91c1c;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gyxxwb-diagnosis {
margin-top: 10px;
}
.gyxxwb-diagnosis-head {
font-size: 12px;
font-weight: 600;
margin-bottom: 6px;
}
.gyxxwb-step-fail {
margin-bottom: 8px;
}
.gyxxwb-step-id {
font-size: 12px;
color: #b91c1c;
margin-bottom: 2px;
}
.gyxxwb-log {
margin-bottom: 8px;
}
.gyxxwb-log-path {
font-size: 11px;
color: #71717a;
margin-bottom: 2px;
word-break: break-all;
}
.gyxxwb-pre {
font-size: 11px;
background: #18181b;
color: #e4e4e7;
border-radius: 6px;
padding: 8px;
max-height: 180px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.gyxxwb-empty {
padding: 16px 12px;
font-size: 12px;
color: #a1a1aa;
text-align: center;
}
.gyxxwb-toasts {
position: absolute;
right: 16px;
bottom: 16px;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: auto;
max-width: 360px;
}
.gyxxwb-toast {
display: flex;
align-items: center;
gap: 8px;
background: #18181b;
color: #fafafa;
border-radius: 10px;
padding: 10px 12px;
font-size: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.gyxxwb-toast button {
border: 1px solid #3f3f46;
background: transparent;
color: #fafafa;
border-radius: 6px;
font-size: 11px;
padding: 2px 8px;
cursor: pointer;
}
.gyxxwb-toast-ok {
background: #14532d;
}
.gyxxwb-toast-error,
.gyxxwb-toast-failed {
background: #7f1d1d;
}
.gyxxwb-footer-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
cursor: pointer;
font-size: 13px;
color: inherit;
padding: 6px 8px;
border-radius: 8px;
}
.gyxxwb-footer-btn:hover,
.gyxxwb-footer-btn-active {
background: rgba(0, 0, 0, 0.06);
}
@media (max-width: 900px) {
.gyxxwb-panel {
left: 0;
width: 100%;
}
}
@media (prefers-color-scheme: dark) {
.gyxxwb-panel {
background: #18181b;
color: #fafafa;
border-right-color: #3f3f46;
}
.gyxxwb-row:hover {
background: #27272a;
}
.gyxxwb-row-selected {
background: #312e81;
}
.gyxxwb-detail {
background: #1f1f22;
border-top-color: #3f3f46;
}
.gyxxwb-actions button,
.gyxxwb-ask button {
background: #27272a;
border-color: #3f3f46;
color: #fafafa;
}
.gyxxwb-ask input,
.gyxxwb-date-row input {
background: #27272a;
border-color: #3f3f46;
color: #fafafa;
}
.gyxxwb-pill {
background: #27272a;
color: #a1a1aa;
}
.gyxxwb-footer-btn:hover,
.gyxxwb-footer-btn-active {
background: rgba(255, 255, 255, 0.08);
}
}