feat(ssa): Complete Phase V-A editable analysis plan variables

Features:
- Add editable variable selection in workflow plan (SingleVarSelect + MultiVarTags)
- Implement 3-layer flexible interception (warning bar + icon + blocking dialog)
- Add tool_param_constraints.json for 12 statistical tools parameter validation
- Add PATCH /workflow/:id/params API with Zod structural validation
- Implement synchronous parameter sync before execution (Promise chaining)
- Fix LLM hallucination by strict system prompt constraints
- Fix DynamicReport object-based rows compatibility (R baseline_table)
- Fix Word export row.map error with same normalization logic
- Restore inferGroupingVar for smart default variable selection
- Add ReactMarkdown rendering in SSAChatPane
- Update SSA module status document to v3.5

Modified files:
- backend: workflow.routes, ChatHandlerService, SystemPromptService, FlowTemplateService
- frontend: WorkflowTimeline, SSAWorkspacePane, DynamicReport, SSAChatPane, ssaStore, ssa.css
- config: tool_param_constraints.json (new)
- docs: SSA status doc, team review reports

Tested: Cohort study end-to-end execution + report export verified
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 13:08:29 +08:00
parent dc6b292308
commit 85fda830c2
27 changed files with 2732 additions and 154 deletions

View File

@@ -1018,4 +1018,4 @@
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
}

View File

@@ -66,13 +66,28 @@ const KVBlock: React.FC<{ block: ReportBlock }> = ({ block }) => {
/* ─── table (增强版rowspan + P值标星 + 横向滚动 + 基线表) ─── */
const TableBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, index }) => {
const headers = block.headers ?? [];
const rows = block.rows ?? [];
if (headers.length === 0 && rows.length === 0) return null;
const rawRows = block.rows ?? [];
if (headers.length === 0 && (!rawRows || (Array.isArray(rawRows) && rawRows.length === 0))) return null;
const isBaselineTable = block.metadata?.is_baseline_table === true;
const isWideTable = headers.length > 6;
const processedRows = useMemo(() => computeRowSpans(rows), [rows]);
// Normalize rows: handle object rows (from R baseline_table) by converting to arrays
const normalizedRows = useMemo(() => {
if (!Array.isArray(rawRows)) return [];
return rawRows.map((row: any) => {
if (Array.isArray(row)) return row;
if (row && typeof row === 'object') {
if (headers.length > 0) {
return headers.map((h: string) => row[h] ?? '');
}
return Object.values(row);
}
return [String(row ?? '')];
});
}, [rawRows, headers]);
const processedRows = useMemo(() => computeRowSpans(normalizedRows), [normalizedRows]);
const pColIndex = useMemo(() => {
const idx = headers.findIndex(h =>
@@ -216,12 +231,14 @@ interface ProcessedRow {
* 连续相同的首列值会被合并
*/
function computeRowSpans(rows: (string | number)[][]): ProcessedRow[] {
if (rows.length === 0) return [];
if (!rows || rows.length === 0) return [];
const processed: ProcessedRow[] = rows.map(row => ({
cells: row.map(cell => ({ value: cell, rowSpan: 1, hidden: false })),
isCategory: false,
}));
const processed: ProcessedRow[] = rows
.filter(row => Array.isArray(row))
.map(row => ({
cells: row.map(cell => ({ value: cell ?? '', rowSpan: 1, hidden: false })),
isCategory: false,
}));
let i = 0;
while (i < processed.length) {

View File

@@ -35,6 +35,8 @@ import { useWorkflow } from '../hooks/useWorkflow';
import { useSSAChat } from '../hooks/useSSAChat';
import type { ChatMessage, ChatIntentType } from '../hooks/useSSAChat';
import type { SSAMessage } from '../types';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { TypeWriter } from './TypeWriter';
import { DataProfileCard } from './DataProfileCard';
import { ClarificationCard } from './ClarificationCard';
@@ -65,13 +67,12 @@ export const SSAChatPane: React.FC = () => {
} = useSSAStore();
const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis();
const { generateDataProfile, handleClarify, executeWorkflow, isProfileLoading, isPlanLoading } = useWorkflow();
const { generateDataProfile, handleClarify, isProfileLoading, isPlanLoading } = useWorkflow();
const {
chatMessages,
isGenerating,
currentIntent,
pendingQuestion,
pendingPlanConfirm,
sendChatMessage,
respondToQuestion,
skipQuestion,
@@ -91,15 +92,6 @@ export const SSAChatPane: React.FC = () => {
const chatEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
// Phase IV: plan_confirmed → 自动触发 executeWorkflow
useEffect(() => {
if (pendingPlanConfirm?.workflowId && currentSession?.id) {
executeWorkflow(currentSession.id, pendingPlanConfirm.workflowId).catch((err: any) => {
addToast(err?.message || '执行失败', 'error');
});
}
}, [pendingPlanConfirm, currentSession?.id, executeWorkflow, addToast]);
// Phase II: session 切换时加载对话历史
useEffect(() => {
if (currentSession?.id) {
@@ -417,7 +409,11 @@ export const SSAChatPane: React.FC = () => {
<span>{msg.content}</span>
</div>
) : (
<div className="chat-msg-content">{msg.content}</div>
<div className="chat-msg-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
)}
</div>
</div>

View File

@@ -3,8 +3,9 @@
*
* 所有渲染基于 currentRecord无 isWorkflowMode 分支。
* record.status 即 phase: planning → executing → completed / error
* Phase V: 支持变量可编辑 + 失配检测弹窗 + 同步阻塞执行
*/
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import {
X,
Play,
@@ -19,14 +20,16 @@ import {
FileQuestion,
ImageOff,
StopCircle,
AlertTriangle,
} from 'lucide-react';
import { useSSAStore } from '../stores/ssaStore';
import type { AnalysisRecord } from '../stores/ssaStore';
import { useWorkflow } from '../hooks/useWorkflow';
import type { TraceStep, ReportBlock, WorkflowStepResult } from '../types';
import { WorkflowTimeline } from './WorkflowTimeline';
import { WorkflowTimeline, detectPlanMismatches } from './WorkflowTimeline';
import { DynamicReport } from './DynamicReport';
import { exportBlocksToWord } from '../utils/exportBlocksToWord';
import apiClient from '@/common/api/axios';
const stepHasResult = (s: WorkflowStepResult) =>
(s.status === 'success' || s.status === 'warning') && s.result;
@@ -41,6 +44,10 @@ export const SSAWorkspacePane: React.FC = () => {
currentSession,
analysisHistory,
updateRecord,
updateStepParams,
hasUnsavedPlanChanges,
setHasUnsavedPlanChanges,
dataContext,
} = useSSAStore();
const { executeWorkflow, cancelWorkflow, isExecuting: isWorkflowExecuting } = useWorkflow();
@@ -48,6 +55,10 @@ export const SSAWorkspacePane: React.FC = () => {
const executionRef = useRef<HTMLDivElement>(null);
const resultRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isPatchingSaving, setIsPatchingSaving] = useState(false);
const [mismatchDialog, setMismatchDialog] = useState<{
mismatches: Array<{ stepNumber: number; toolName: string; param: string; message: string }>;
} | null>(null);
// ---- Derive everything from the current record ----
const record: AnalysisRecord | null =
@@ -85,15 +96,77 @@ export const SSAWorkspacePane: React.FC = () => {
const handleClose = () => setWorkspaceOpen(false);
const { dataProfile } = useSSAStore();
const variableDictionary = dataContext.variableDictionary;
// Primary: dataContext.dataOverview, Fallback: dataProfile.columns
const dataOverviewColumns = React.useMemo(() => {
const ctxCols = dataContext.dataOverview?.profile?.columns;
if (ctxCols && ctxCols.length > 0) return ctxCols;
if (dataProfile?.columns && dataProfile.columns.length > 0) {
return dataProfile.columns.map(c => ({
name: c.name,
type: c.inferred_type as 'numeric' | 'categorical' | 'datetime' | 'text',
missingCount: c.null_count ?? 0,
missingRate: (c.null_ratio ?? 0) * 100,
uniqueCount: c.unique_count ?? 0,
totalCount: c.non_null_count + (c.null_count ?? 0),
totalLevels: c.inferred_type === 'categorical' ? (c.top_categories?.length ?? c.unique_count) : undefined,
isIdLike: false,
}));
}
return [];
}, [dataContext.dataOverview, dataProfile]);
const handleStepParamsChange = useCallback((stepNumber: number, params: Record<string, unknown>) => {
if (!record) return;
updateStepParams(record.id, stepNumber, params);
}, [record, updateStepParams]);
const handleRun = async () => {
if (!plan || !currentSession || !record) return;
// Layer 3: mismatch detection before execution
if (variableDictionary.length > 0) {
const mismatches = detectPlanMismatches(plan, variableDictionary, dataOverviewColumns);
if (mismatches.length > 0) {
setMismatchDialog({ mismatches });
return;
}
}
await doExecute();
};
const doExecute = async () => {
if (!plan || !currentSession || !record) return;
try {
setIsPatchingSaving(true);
// Sync block: PATCH modified params before execution
if (hasUnsavedPlanChanges) {
const stepsPayload = plan.steps.map(s => ({
stepOrder: s.step_number,
params: s.params,
}));
await apiClient.patch(`/api/v1/ssa/workflow/${plan.workflow_id}/params`, { steps: stepsPayload });
setHasUnsavedPlanChanges(false);
}
setIsPatchingSaving(false);
await executeWorkflow(currentSession.id, plan.workflow_id);
} catch (err: any) {
setIsPatchingSaving(false);
addToast(err?.message || '执行失败,请重试', 'error');
}
};
const handleForceExecute = async () => {
setMismatchDialog(null);
await doExecute();
};
const handleCancel = () => {
cancelWorkflow();
if (record) updateRecord(record.id, { status: 'planning' });
@@ -139,6 +212,38 @@ export const SSAWorkspacePane: React.FC = () => {
return (
<section className={`ssa-workspace-pane ${workspaceOpen ? 'open' : ''}`}>
{/* Mismatch Informed Consent Dialog */}
{mismatchDialog && (
<div className="wt-mismatch-overlay">
<div className="wt-mismatch-dialog">
<div className="wt-mismatch-header">
<AlertTriangle size={20} />
<h3></h3>
</div>
<p className="wt-mismatch-desc">
AI
</p>
<div className="wt-mismatch-list">
{mismatchDialog.mismatches.map((m, i) => (
<div key={i} className="wt-mismatch-item">
<span className="wt-mismatch-step">{m.stepNumber} {m.toolName}</span>
<span className="wt-mismatch-param">{m.param}</span>
<span className="wt-mismatch-msg">{m.message}</span>
</div>
))}
</div>
<div className="wt-mismatch-actions">
<button className="wt-mismatch-cancel" onClick={() => setMismatchDialog(null)}>
</button>
<button className="wt-mismatch-force" onClick={handleForceExecute}>
</button>
</div>
</div>
</div>
)}
<div className="workspace-inner">
{/* Header + step bar */}
<header className="workspace-header">
@@ -224,6 +329,11 @@ export const SSAWorkspacePane: React.FC = () => {
stepResults={steps}
currentStep={steps.find((s) => s.status === 'running')?.step_number}
isExecuting={isWorkflowExecuting}
editable={phase === 'planning'}
recordId={record?.id}
variableDictionary={variableDictionary}
dataOverviewColumns={dataOverviewColumns}
onStepParamsChange={handleStepParamsChange}
/>
<div className="sap-actions">
@@ -243,9 +353,18 @@ export const SSAWorkspacePane: React.FC = () => {
</button>
) : (
<button className="run-btn" onClick={handleRun} disabled={phase !== 'planning'}>
<Play size={14} />
<button className="run-btn" onClick={handleRun} disabled={phase !== 'planning' || isPatchingSaving}>
{isPatchingSaving ? (
<>
<Loader2 size={14} className="spin" />
...
</>
) : (
<>
<Play size={14} />
</>
)}
</button>
)}
</div>

View File

@@ -1,9 +1,10 @@
/**
* WorkflowTimeline - 多步骤分析计划时间线
* WorkflowTimeline - 多步骤分析计划时间线(可编辑版)
*
* 精美卡片式布局:标题区 → 护栏横幅 → 步骤卡片列表 → 底部提示
* Phase V: 变量参数可编辑,含柔性拦截三层防线
*/
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import {
FlaskConical,
BarChart3,
@@ -14,14 +15,135 @@ import {
Loader2,
Clock,
ListChecks,
ChevronDown,
X as XIcon,
Plus,
} from 'lucide-react';
import type { WorkflowPlan, WorkflowStepDef, WorkflowStepResult, WorkflowStepStatus } from '../types';
import type {
WorkflowPlan,
WorkflowStepDef,
WorkflowStepResult,
WorkflowStepStatus,
VariableDictEntry,
DataOverviewColumn,
} from '../types';
// ────────────────────────── Param constraints ──────────────────────────
interface ParamConstraint {
paramType: 'single' | 'multi';
requiredType: 'categorical' | 'numeric' | 'any';
minLevels?: number;
maxLevels?: number;
hint: string;
}
type ToolConstraints = Record<string, Record<string, ParamConstraint>>;
const TOOL_CONSTRAINTS: ToolConstraints = {
ST_DESCRIPTIVE: {
variables: { paramType: 'multi', requiredType: 'any', hint: '选择需要描述的变量' },
group_var: { paramType: 'single', requiredType: 'categorical', hint: '分组变量(可选)' },
},
ST_T_TEST_IND: {
group_var: { paramType: 'single', requiredType: 'categorical', maxLevels: 2, hint: 'T检验要求二分类分组变量' },
value_var: { paramType: 'single', requiredType: 'numeric', hint: 'T检验要求连续型因变量' },
},
ST_MANN_WHITNEY: {
group_var: { paramType: 'single', requiredType: 'categorical', maxLevels: 2, hint: 'Mann-Whitney检验要求二分类分组变量' },
value_var: { paramType: 'single', requiredType: 'numeric', hint: '要求连续型因变量' },
},
ST_T_TEST_PAIRED: {
before_var: { paramType: 'single', requiredType: 'numeric', hint: '前测变量应为连续型' },
after_var: { paramType: 'single', requiredType: 'numeric', hint: '后测变量应为连续型' },
},
ST_WILCOXON: {
before_var: { paramType: 'single', requiredType: 'numeric', hint: '前测变量应为连续型' },
after_var: { paramType: 'single', requiredType: 'numeric', hint: '后测变量应为连续型' },
},
ST_CHI_SQUARE: {
var1: { paramType: 'single', requiredType: 'categorical', hint: '卡方检验要求分类变量' },
var2: { paramType: 'single', requiredType: 'categorical', hint: '卡方检验要求分类变量' },
},
ST_FISHER: {
var1: { paramType: 'single', requiredType: 'categorical', hint: 'Fisher检验要求分类变量' },
var2: { paramType: 'single', requiredType: 'categorical', hint: 'Fisher检验要求分类变量' },
},
ST_CORRELATION: {
var_x: { paramType: 'single', requiredType: 'numeric', hint: '相关分析要求连续型变量' },
var_y: { paramType: 'single', requiredType: 'numeric', hint: '相关分析要求连续型变量' },
},
ST_LOGISTIC_BINARY: {
outcome_var: { paramType: 'single', requiredType: 'categorical', maxLevels: 2, hint: '二元Logistic回归要求二分类结局变量' },
predictors: { paramType: 'multi', requiredType: 'any', hint: '预测变量' },
confounders: { paramType: 'multi', requiredType: 'any', hint: '混杂因素(可选)' },
},
ST_LINEAR_REG: {
outcome_var: { paramType: 'single', requiredType: 'numeric', hint: '线性回归要求连续型结局变量' },
predictors: { paramType: 'multi', requiredType: 'any', hint: '预测变量' },
confounders: { paramType: 'multi', requiredType: 'any', hint: '混杂因素(可选)' },
},
ST_ANOVA_ONE: {
group_var: { paramType: 'single', requiredType: 'categorical', minLevels: 3, hint: 'ANOVA要求3组及以上分组变量' },
value_var: { paramType: 'single', requiredType: 'numeric', hint: '要求连续型因变量' },
},
ST_BASELINE_TABLE: {
group_var: { paramType: 'single', requiredType: 'categorical', minLevels: 2, maxLevels: 5, hint: '基线表需要分类分组变量' },
analyze_vars: { paramType: 'multi', requiredType: 'any', hint: '选择需要分析的变量' },
},
};
const SINGLE_VAR_KEYS = new Set([
'group_var', 'outcome_var', 'value_var', 'var_x', 'var_y',
'before_var', 'after_var', 'var1', 'var2',
]);
const MULTI_VAR_KEYS = new Set([
'analyze_vars', 'predictors', 'variables', 'confounders',
]);
function isVariableParam(key: string): boolean {
return SINGLE_VAR_KEYS.has(key) || MULTI_VAR_KEYS.has(key);
}
interface VarInfo {
name: string;
type: string;
totalLevels?: number;
}
function checkMismatch(
varName: string,
constraint: ParamConstraint,
varsMap: Map<string, VarInfo>
): string | null {
if (varsMap.size === 0) return null;
const v = varsMap.get(varName);
if (!v) return null;
if (constraint.requiredType === 'any') return null;
if (constraint.requiredType !== v.type) {
return `${constraint.hint}(当前:${v.type === 'numeric' ? '连续型' : '分类型'}`;
}
if (constraint.maxLevels && v.totalLevels && v.totalLevels > constraint.maxLevels) {
return `要求最多${constraint.maxLevels}个分类水平,当前${v.totalLevels}`;
}
if (constraint.minLevels && v.totalLevels && v.totalLevels < constraint.minLevels) {
return `要求至少${constraint.minLevels}个分类水平,当前${v.totalLevels}`;
}
return null;
}
// ────────────────────────── Props & Styles ──────────────────────────
interface WorkflowTimelineProps {
plan: WorkflowPlan;
stepResults?: WorkflowStepResult[];
currentStep?: number;
isExecuting?: boolean;
editable?: boolean;
recordId?: string;
variableDictionary?: VariableDictEntry[];
dataOverviewColumns?: DataOverviewColumn[];
onStepParamsChange?: (stepNumber: number, params: Record<string, unknown>) => void;
}
const statusStyle: Record<WorkflowStepStatus | 'pending', {
@@ -91,26 +213,249 @@ const PARAM_LABELS: Record<string, string> = {
conf_level: '置信水平',
var_x: '变量 X',
var_y: '变量 Y',
before_var: '前测变量',
after_var: '后测变量',
var1: '变量 1',
var2: '变量 2',
confounders: '混杂因素',
analyze_vars: '分析变量',
};
// ────────────────────────── SingleVarSelect ──────────────────────────
interface SingleVarSelectProps {
value: string | null;
constraint: ParamConstraint | undefined;
varsMap: Map<string, VarInfo>;
allVars: VarInfo[];
onChange: (v: string | null) => void;
}
const SingleVarSelect: React.FC<SingleVarSelectProps> = ({ value, constraint, varsMap, allVars, onChange }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const mismatch = value && constraint ? checkMismatch(value, constraint, varsMap) : null;
const categoricalVars = allVars.filter(v => v.type === 'categorical');
const numericVars = allVars.filter(v => v.type === 'numeric');
const renderOption = (v: VarInfo) => {
const warn = constraint ? checkMismatch(v.name, constraint, varsMap) : null;
return (
<div
key={v.name}
className={`wt-var-option ${v.name === value ? 'selected' : ''} ${warn ? 'warn' : ''}`}
onClick={() => { onChange(v.name); setOpen(false); }}
title={warn || undefined}
>
<span className={`wt-var-type-dot ${v.type}`} />
<span>{v.name}</span>
{v.totalLevels !== undefined && <span className="wt-var-levels">{v.totalLevels}</span>}
{warn && <AlertTriangle size={12} className="wt-var-warn-icon" />}
</div>
);
};
return (
<div className="wt-var-select-wrap" ref={ref}>
<button
className={`wt-var-select-btn ${mismatch ? 'mismatch' : ''}`}
onClick={() => setOpen(!open)}
title={mismatch || undefined}
>
{value ? (
<>
<span className={`wt-var-type-dot ${varsMap.get(value)?.type || 'unknown'}`} />
<span>{value}</span>
{mismatch && <AlertTriangle size={12} className="wt-var-warn-icon" />}
</>
) : (
<span className="wt-var-placeholder">...</span>
)}
<ChevronDown size={14} className={`wt-var-chevron ${open ? 'open' : ''}`} />
</button>
{value && (
<button className="wt-var-clear-btn" onClick={() => onChange(null)} title="清除">
<XIcon size={12} />
</button>
)}
{open && (
<div className="wt-var-dropdown">
{categoricalVars.length > 0 && (
<>
<div className="wt-var-type-group"></div>
{categoricalVars.map(renderOption)}
</>
)}
{numericVars.length > 0 && (
<>
<div className="wt-var-type-group"></div>
{numericVars.map(renderOption)}
</>
)}
</div>
)}
</div>
);
};
// ────────────────────────── MultiVarTags ──────────────────────────
interface MultiVarTagsProps {
values: string[];
constraint: ParamConstraint | undefined;
varsMap: Map<string, VarInfo>;
allVars: VarInfo[];
onChange: (v: string[]) => void;
}
const MultiVarTags: React.FC<MultiVarTagsProps> = ({ values, constraint, varsMap, allVars, onChange }) => {
const [showDropdown, setShowDropdown] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setShowDropdown(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const removeVar = (name: string) => onChange(values.filter(v => v !== name));
const addVar = (name: string) => {
if (!values.includes(name)) onChange([...values, name]);
};
const unselected = allVars.filter(v => !values.includes(v.name));
const unselectedCat = unselected.filter(v => v.type === 'categorical');
const unselectedNum = unselected.filter(v => v.type === 'numeric');
const selectAllType = (type: string) => {
const toAdd = allVars.filter(v => v.type === type && !values.includes(v.name)).map(v => v.name);
onChange([...values, ...toAdd]);
};
return (
<div className="wt-var-tags-wrap" ref={ref}>
<div className="wt-var-tags">
{values.map(name => {
const info = varsMap.get(name);
const warn = constraint ? checkMismatch(name, constraint, varsMap) : null;
return (
<span
key={name}
className={`wt-var-tag ${info?.type || 'unknown'} ${warn ? 'warn' : ''}`}
title={warn || undefined}
>
{name}
{warn && <AlertTriangle size={10} className="wt-var-warn-icon-sm" />}
<button className="wt-var-tag-remove" onClick={() => removeVar(name)}>
<XIcon size={10} />
</button>
</span>
);
})}
<button className="wt-var-add-btn" onClick={() => setShowDropdown(!showDropdown)}>
<Plus size={12} />
<span></span>
</button>
</div>
{showDropdown && unselected.length > 0 && (
<div className="wt-var-dropdown multi">
<div className="wt-var-quick-actions">
{unselectedCat.length > 0 && (
<button className="wt-var-quick-btn" onClick={() => selectAllType('categorical')}>
</button>
)}
{unselectedNum.length > 0 && (
<button className="wt-var-quick-btn" onClick={() => selectAllType('numeric')}>
</button>
)}
</div>
{unselectedCat.length > 0 && (
<>
<div className="wt-var-type-group"></div>
{unselectedCat.map(v => (
<div key={v.name} className="wt-var-option" onClick={() => addVar(v.name)}>
<span className={`wt-var-type-dot ${v.type}`} />
<span>{v.name}</span>
{v.totalLevels !== undefined && <span className="wt-var-levels">{v.totalLevels}</span>}
</div>
))}
</>
)}
{unselectedNum.length > 0 && (
<>
<div className="wt-var-type-group"></div>
{unselectedNum.map(v => (
<div key={v.name} className="wt-var-option" onClick={() => addVar(v.name)}>
<span className={`wt-var-type-dot ${v.type}`} />
<span>{v.name}</span>
</div>
))}
</>
)}
</div>
)}
</div>
);
};
// ────────────────────────── StepCard ──────────────────────────
interface StepCardProps {
step: WorkflowStepDef;
result?: WorkflowStepResult;
isLast: boolean;
isCurrent: boolean;
editable: boolean;
varsMap: Map<string, VarInfo>;
allVars: VarInfo[];
onParamChange?: (key: string, value: unknown) => void;
}
const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent }) => {
const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent, editable, varsMap, allVars, onParamChange }) => {
const status: WorkflowStepStatus | 'pending' = result?.status || 'pending';
const s = statusStyle[status];
const toolCode = step.tool_code;
const toolConstraints = TOOL_CONSTRAINTS[toolCode];
const visibleParams = step.params
? Object.entries(step.params).filter(([k]) => !HIDDEN_PARAMS.has(k))
: [];
const mismatches: string[] = [];
if (toolConstraints && step.params) {
for (const [key, value] of Object.entries(step.params)) {
const c = toolConstraints[key];
if (!c || !isVariableParam(key)) continue;
if (c.paramType === 'single' && typeof value === 'string' && value) {
const warn = checkMismatch(value, c, varsMap);
if (warn) mismatches.push(`${PARAM_LABELS[key] || key}: ${warn}`);
} else if (c.paramType === 'multi' && Array.isArray(value)) {
for (const v of value) {
const warn = checkMismatch(String(v), c, varsMap);
if (warn) mismatches.push(`${PARAM_LABELS[key] || key}${v}: ${warn}`);
}
}
}
}
const canEdit = editable && status === 'pending';
return (
<div className={`wt-step-row ${isCurrent ? 'wt-current' : ''}`}>
{/* Left rail */}
<div className="wt-rail">
<StatusDot status={status} num={step.step_number} />
{!isLast && (
@@ -121,22 +466,31 @@ const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent })
)}
</div>
{/* Card */}
<div className="wt-card" style={{ borderLeftColor: s.borderColor }}>
{mismatches.length > 0 && (
<div className="wt-step-warning-bar">
<AlertTriangle size={13} />
<span></span>
</div>
)}
<div className="wt-card-head">
<div className="wt-card-title-row">
<span className="wt-step-badge"> {step.step_number}</span>
<span className="wt-tool-name">{step.tool_name}</span>
{step.is_sensitivity && <span className="wt-sensitivity"></span>}
{mismatches.length > 0 && (
<span className="wt-step-warning-icon" title={mismatches.join('\n')}>
<AlertTriangle size={14} />
</span>
)}
</div>
{result?.duration_ms != null && (
<span className="wt-duration">{result.duration_ms}ms</span>
)}
</div>
{step.description && (
<p className="wt-card-desc">{step.description}</p>
)}
{step.description && <p className="wt-card-desc">{step.description}</p>}
{step.switch_condition && (
<div className="wt-guardrail-inline">
@@ -147,12 +501,37 @@ const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent })
{visibleParams.length > 0 && (
<div className="wt-params-grid">
{visibleParams.slice(0, 5).map(([key, value]) => (
<div key={key} className="wt-param-item">
<span className="wt-param-label">{PARAM_LABELS[key] || key}</span>
<span className="wt-param-val">{formatValue(value)}</span>
</div>
))}
{visibleParams.map(([key, value]) => {
const constraint = toolConstraints?.[key];
const isVarParam = isVariableParam(key);
const isSingle = SINGLE_VAR_KEYS.has(key);
const isMulti = MULTI_VAR_KEYS.has(key);
return (
<div key={key} className="wt-param-item">
<span className="wt-param-label">{PARAM_LABELS[key] || key}</span>
{canEdit && isVarParam && isSingle ? (
<SingleVarSelect
value={typeof value === 'string' ? value : null}
constraint={constraint}
varsMap={varsMap}
allVars={allVars}
onChange={(v) => onParamChange?.(key, v)}
/>
) : canEdit && isVarParam && isMulti ? (
<MultiVarTags
values={Array.isArray(value) ? value.map(String) : []}
constraint={constraint}
varsMap={varsMap}
allVars={allVars}
onChange={(v) => onParamChange?.(key, v)}
/>
) : (
<span className="wt-param-val">{formatValue(value)}</span>
)}
</div>
);
})}
</div>
)}
@@ -176,19 +555,70 @@ const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent })
);
};
// ────────────────────────── Main Component ──────────────────────────
export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
plan,
stepResults = [],
currentStep,
isExecuting = false,
editable = false,
variableDictionary = [],
dataOverviewColumns = [],
onStepParamsChange,
}) => {
const getResult = (n: number) => stepResults.find(r => r.step_number === n);
const done = stepResults.filter(r => r.status === 'success').length;
const pct = plan.total_steps > 0 ? (done / plan.total_steps) * 100 : 0;
const varsMap = React.useMemo(() => {
const map = new Map<string, VarInfo>();
for (const v of variableDictionary) {
const col = dataOverviewColumns.find(c => c.name === v.name);
map.set(v.name, {
name: v.name,
type: v.confirmedType || v.inferredType,
totalLevels: col?.totalLevels,
});
}
// Fallback: use dataOverviewColumns for entries not yet in variableDictionary
for (const col of dataOverviewColumns) {
if (!map.has(col.name)) {
map.set(col.name, {
name: col.name,
type: col.type,
totalLevels: col.totalLevels,
});
}
}
return map;
}, [variableDictionary, dataOverviewColumns]);
const allVars = React.useMemo(() => {
if (variableDictionary.length > 0) {
return variableDictionary
.filter(v => !v.isIdLike)
.map(v => {
const col = dataOverviewColumns.find(c => c.name === v.name);
return {
name: v.name,
type: v.confirmedType || v.inferredType,
totalLevels: col?.totalLevels,
};
});
}
// Fallback: build from dataOverviewColumns when variableDictionary is empty
return dataOverviewColumns
.filter(c => !c.isIdLike)
.map(c => ({
name: c.name,
type: c.type,
totalLevels: c.totalLevels,
}));
}, [variableDictionary, dataOverviewColumns]);
return (
<div className="wt-root">
{/* Header */}
<div className="wt-header">
<div className="wt-header-icon">
<FlaskConical size={20} />
@@ -207,11 +637,15 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
{plan.estimated_time_seconds < 60 ? `${plan.estimated_time_seconds}` : `${Math.ceil(plan.estimated_time_seconds / 60)}分钟`}
</span>
)}
{editable && (
<span className="wt-meta-item wt-editable-hint">
</span>
)}
</div>
</div>
</div>
{/* EPV Warning */}
{plan.epv_warning && (
<div className="wt-banner wt-banner-warn">
<AlertTriangle size={15} />
@@ -219,7 +653,6 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
</div>
)}
{/* Guardrail Banner */}
{plan.planned_trace?.fallbackTool && (
<div className="wt-banner wt-banner-guard">
<Shield size={15} />
@@ -230,7 +663,6 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
</div>
)}
{/* Progress */}
{isExecuting && (
<div className="wt-progress-bar-wrap">
<div className="wt-progress-track">
@@ -240,7 +672,6 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
</div>
)}
{/* Steps */}
<div className="wt-steps">
{plan.steps.map((step, i) => (
<StepCard
@@ -249,11 +680,14 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
result={getResult(step.step_number)}
isLast={i === plan.steps.length - 1}
isCurrent={currentStep === step.step_number}
editable={editable}
varsMap={varsMap}
allVars={allVars}
onParamChange={(key, value) => onStepParamsChange?.(step.step_number, { [key]: value })}
/>
))}
</div>
{/* Footer */}
{!isExecuting && stepResults.length === 0 && (
<div className="wt-footer">
<BarChart3 size={14} />
@@ -265,3 +699,51 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
};
export default WorkflowTimeline;
/**
* Utility: detect param mismatches for a plan (used by SSAWorkspacePane mismatch dialog)
*/
export function detectPlanMismatches(
plan: WorkflowPlan,
variableDictionary: VariableDictEntry[],
dataOverviewColumns: DataOverviewColumn[]
): Array<{ stepNumber: number; toolName: string; param: string; message: string }> {
const varsMap = new Map<string, VarInfo>();
for (const v of variableDictionary) {
const col = dataOverviewColumns.find(c => c.name === v.name);
varsMap.set(v.name, {
name: v.name,
type: v.confirmedType || v.inferredType,
totalLevels: col?.totalLevels,
});
}
for (const col of dataOverviewColumns) {
if (!varsMap.has(col.name)) {
varsMap.set(col.name, { name: col.name, type: col.type, totalLevels: col.totalLevels });
}
}
const results: Array<{ stepNumber: number; toolName: string; param: string; message: string }> = [];
for (const step of plan.steps) {
const toolConstraints = TOOL_CONSTRAINTS[step.tool_code];
if (!toolConstraints || !step.params) continue;
for (const [key, value] of Object.entries(step.params)) {
const c = toolConstraints[key];
if (!c || !isVariableParam(key)) continue;
if (c.paramType === 'single' && typeof value === 'string' && value) {
const warn = checkMismatch(value, c, varsMap);
if (warn) results.push({ stepNumber: step.step_number, toolName: step.tool_name, param: PARAM_LABELS[key] || key, message: warn });
} else if (c.paramType === 'multi' && Array.isArray(value)) {
for (const v of value) {
const warn = checkMismatch(String(v), c, varsMap);
if (warn) results.push({ stepNumber: step.step_number, toolName: step.tool_name, param: `${PARAM_LABELS[key] || key}${v}`, message: warn });
}
}
}
}
return results;
}

View File

@@ -239,18 +239,20 @@ export function useSSAChat(): UseSSAChatReturn {
}
// analysis_plan 事件Phase IV: 对话驱动分析)
// 仅创建记录,不打开工作区 — 等用户确认方案后再打开
if (parsed.type === 'analysis_plan' && parsed.plan) {
const plan = parsed.plan as WorkflowPlan;
const { addRecord, setActivePane, setWorkspaceOpen } = useSSAStore.getState();
const { addRecord } = useSSAStore.getState();
addRecord(content, plan);
setActivePane('sap');
setWorkspaceOpen(true);
continue;
}
// plan_confirmed 事件Phase IV: 用户确认方案后触发执行
if (parsed.type === 'plan_confirmed' && parsed.workflowId) {
setPendingPlanConfirm({ workflowId: parsed.workflowId });
// plan_confirmed 事件Phase IV: 用户确认方案后打开工作区
// 不自动触发 executeWorkflow — 由用户在工作区手动点击「开始执行分析」
if (parsed.type === 'plan_confirmed') {
const { setActivePane, setWorkspaceOpen } = useSSAStore.getState();
setActivePane('sap');
setWorkspaceOpen(true);
setPendingQuestion(null);
continue;
}
@@ -319,14 +321,27 @@ export function useSSAChat(): UseSSAChatReturn {
/**
* 响应 ask_user 卡片Phase III
* 将 value 解析为中文 label 用于显示
*/
const respondToQuestion = useCallback(async (sessionId: string, response: AskUserResponseData) => {
const question = pendingQuestion;
setPendingQuestion(null);
const displayText = response.action === 'select'
? `选择了: ${response.selectedValues?.join(', ')}`
: response.freeText || '(已回复)';
let displayText: string;
if (response.action === 'select' && response.selectedValues) {
const labels = response.selectedValues.map(val => {
const opt = question?.options?.find(o => o.value === val);
return opt?.label || val;
});
displayText = `选择了 ${labels.join('、')}`;
} else if (response.action === 'free_text') {
displayText = response.freeText || '(已回复)';
} else {
displayText = '(已回复)';
}
await sendChatMessage(sessionId, displayText, { askUserResponse: response });
}, [sendChatMessage]);
}, [sendChatMessage, pendingQuestion]);
/**
* H1: 跳过 ask_user 卡片
@@ -337,7 +352,7 @@ export function useSSAChat(): UseSSAChatReturn {
questionId,
action: 'skip',
};
await sendChatMessage(sessionId, '跳过此问题', { askUserResponse: skipResponse });
await sendChatMessage(sessionId, '跳过此问题', { askUserResponse: skipResponse });
}, [sendChatMessage]);
return {

View File

@@ -9,6 +9,7 @@
import React, { useEffect } from 'react';
import { useSSAStore } from './stores/ssaStore';
import SSAWorkspace from './SSAWorkspace';
import './styles/ssa.css';
import './styles/ssa-workspace.css';
const SSAModule: React.FC = () => {

View File

@@ -97,6 +97,10 @@ interface SSAState {
addRecord: (query: string, plan: WorkflowPlan) => string;
updateRecord: (id: string, patch: Partial<Omit<AnalysisRecord, 'id'>>) => void;
selectRecord: (id: string) => void;
updateStepParams: (recordId: string, stepNumber: number, params: Record<string, unknown>) => void;
hasUnsavedPlanChanges: boolean;
setHasUnsavedPlanChanges: (v: boolean) => void;
// Data profile
setDataProfile: (profile: DataProfile | null) => void;
@@ -136,6 +140,7 @@ const initialState = {
dataProfileLoading: false,
dataProfileModalVisible: false,
workflowPlanLoading: false,
hasUnsavedPlanChanges: false,
dataContext: {
dataOverview: null,
variableDictionary: [],
@@ -233,6 +238,21 @@ export const useSSAStore = create<SSAState>((set) => ({
});
},
updateStepParams: (recordId, stepNumber, params) => {
set((state) => ({
analysisHistory: state.analysisHistory.map((r) => {
if (r.id !== recordId || !r.plan) return r;
const updatedSteps = r.plan.steps.map((s) =>
s.step_number === stepNumber ? { ...s, params: { ...s.params, ...params } } : s
);
return { ...r, plan: { ...r.plan, steps: updatedSteps } };
}),
hasUnsavedPlanChanges: true,
}));
},
setHasUnsavedPlanChanges: (v) => set({ hasUnsavedPlanChanges: v }),
// Data profile
setDataProfile: (profile) => set({ dataProfile: profile }),
setDataProfileLoading: (loading) => set({ dataProfileLoading: loading }),

View File

@@ -671,3 +671,552 @@
color: #94a3b8;
white-space: nowrap;
}
/* ============================================ */
/* Phase III: AskUser 交互卡片 */
/* ============================================ */
.ask-user-card {
background: #ffffff;
border: 1px solid #e0e7ef;
border-radius: 10px;
padding: 14px 16px;
max-width: 420px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.ask-user-question {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13.5px;
font-weight: 500;
color: #1e293b;
line-height: 1.5;
margin-bottom: 8px;
}
.ask-user-question svg { margin-top: 2px; }
.ask-user-context {
font-size: 12.5px;
color: #64748b;
line-height: 1.5;
margin-bottom: 12px;
padding-left: 22px;
}
.ask-user-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-left: 22px;
}
.ask-user-option-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #374151;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.ask-user-option-btn:hover {
border-color: #3b82f6;
color: #3b82f6;
background: #eff6ff;
}
.ask-user-option-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ask-user-option-btn.confirm-primary {
background: #3b82f6;
color: #fff;
border-color: #3b82f6;
}
.ask-user-option-btn.confirm-primary:hover {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.ask-user-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #374151;
transition: all 0.12s;
width: 100%;
}
.ask-user-checkbox-label:hover { background: #f9fafb; }
.ask-user-free-text {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.ask-user-textarea {
width: 100%;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
resize: vertical;
outline: none;
transition: border-color 0.15s;
}
.ask-user-textarea:focus { border-color: #3b82f6; }
.ask-user-submit-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 16px;
border: none;
border-radius: 6px;
background: #3b82f6;
color: #fff;
font-size: 13px;
cursor: pointer;
align-self: flex-end;
transition: background 0.15s;
}
.ask-user-submit-btn:hover { background: #2563eb; }
.ask-user-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.option-label { white-space: nowrap; }
.option-desc { font-size: 11px; color: #94a3b8; }
.ask-user-skip {
padding-left: 22px;
margin-top: 10px;
border-top: 1px solid #f1f5f9;
padding-top: 8px;
}
.ask-user-skip-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: none;
border-radius: 5px;
background: transparent;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.ask-user-skip-btn:hover {
background: #f1f5f9;
color: #64748b;
}
/* ============================================ */
/* Phase II: Chat Markdown 渲染样式 */
/* ============================================ */
.chat-msg-content {
font-size: 14px;
line-height: 1.7;
color: #1e293b;
}
.user-bubble .chat-msg-content,
.user-bubble .chat-msg-content p,
.user-bubble .chat-msg-content strong,
.user-bubble .chat-msg-content a {
color: inherit;
}
.chat-msg-content p { margin: 0 0 8px 0; }
.chat-msg-content p:last-child { margin-bottom: 0; }
.chat-msg-content strong { font-weight: 600; color: #0f172a; }
.chat-msg-content h3 { font-size: 15px; font-weight: 600; margin: 12px 0 6px; color: #0f172a; }
.chat-msg-content h4 { font-size: 14px; font-weight: 600; margin: 10px 0 4px; color: #1e293b; }
.chat-msg-content ul, .chat-msg-content ol {
padding-left: 20px;
margin: 6px 0;
}
.chat-msg-content li { margin-bottom: 3px; }
.chat-msg-content code {
padding: 1px 5px;
background: #f1f5f9;
border-radius: 4px;
font-size: 12.5px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #e11d48;
}
.chat-msg-content pre {
background: #1e293b;
color: #e2e8f0;
padding: 12px 14px;
border-radius: 8px;
overflow-x: auto;
font-size: 12.5px;
margin: 8px 0;
}
.chat-msg-content pre code {
background: none;
color: inherit;
padding: 0;
}
.chat-msg-content table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
font-size: 13px;
}
.chat-msg-content thead th {
background: #f8fafc;
padding: 6px 10px;
text-align: left;
font-weight: 600;
color: #475569;
border-bottom: 2px solid #e2e8f0;
white-space: nowrap;
}
.chat-msg-content tbody td {
padding: 5px 10px;
border-bottom: 1px solid #f1f5f9;
color: #334155;
}
.chat-msg-content tbody tr:hover { background: #f8fafc; }
.chat-msg-content hr {
border: none;
border-top: 1px solid #e2e8f0;
margin: 12px 0;
}
.chat-msg-content blockquote {
border-left: 3px solid #3b82f6;
padding-left: 12px;
margin: 8px 0;
color: #64748b;
}
/* ====================================================================
Phase V: Variable Editable Controls + Warning Styles
==================================================================== */
/* ── Editable hint in header ── */
.wt-editable-hint {
color: #3b82f6;
font-size: 11px;
font-style: italic;
}
/* ── Step Warning Bar (Layer 1) ── */
.wt-step-warning-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #fef3c7;
border-bottom: 1px solid #fde68a;
border-radius: 8px 8px 0 0;
font-size: 12px;
color: #92400e;
margin: -12px -12px 8px -12px;
}
/* ── Step Warning Icon (Layer 2) ── */
.wt-step-warning-icon {
display: inline-flex;
color: #f59e0b;
margin-left: 6px;
cursor: help;
}
/* ── Single Var Select ── */
.wt-var-select-wrap {
position: relative;
display: inline-flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
}
.wt-var-select-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
min-width: 120px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.wt-var-select-btn:hover {
border-color: #93c5fd;
box-shadow: 0 0 0 2px rgba(59,130,246,0.08);
}
.wt-var-select-btn.mismatch {
border-color: #fbbf24;
background: #fffbeb;
}
.wt-var-placeholder { color: #94a3b8; }
.wt-var-chevron { transition: transform 0.15s; flex-shrink: 0; color: #94a3b8; }
.wt-var-chevron.open { transform: rotate(180deg); }
.wt-var-clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
background: #f1f5f9;
border-radius: 50%;
cursor: pointer;
color: #64748b;
flex-shrink: 0;
}
.wt-var-clear-btn:hover { background: #fee2e2; color: #dc2626; }
/* ── Variable Type Dot ── */
.wt-var-type-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.wt-var-type-dot.categorical { background: #a855f7; }
.wt-var-type-dot.numeric { background: #3b82f6; }
.wt-var-type-dot.datetime { background: #f97316; }
.wt-var-type-dot.text { background: #64748b; }
.wt-var-type-dot.unknown { background: #cbd5e1; }
/* ── Dropdown ── */
.wt-var-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 50;
min-width: 200px;
max-height: 280px;
overflow-y: auto;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
padding: 4px 0;
}
.wt-var-dropdown.multi { min-width: 240px; }
.wt-var-type-group {
padding: 6px 12px 3px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #94a3b8;
}
.wt-var-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: background 0.1s;
}
.wt-var-option:hover { background: #f1f5f9; }
.wt-var-option.selected { background: #eff6ff; font-weight: 500; }
.wt-var-option.warn { color: #92400e; }
.wt-var-levels {
margin-left: auto;
font-size: 10px;
color: #94a3b8;
background: #f1f5f9;
padding: 1px 5px;
border-radius: 4px;
}
.wt-var-warn-icon { color: #f59e0b; flex-shrink: 0; }
.wt-var-warn-icon-sm { color: #f59e0b; margin-left: 2px; }
/* ── Quick Actions in dropdown ── */
.wt-var-quick-actions {
display: flex;
gap: 6px;
padding: 6px 10px;
border-bottom: 1px solid #f1f5f9;
}
.wt-var-quick-btn {
font-size: 11px;
padding: 2px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
cursor: pointer;
color: #475569;
}
.wt-var-quick-btn:hover { background: #eff6ff; border-color: #93c5fd; }
/* ── Multi Var Tags ── */
.wt-var-tags-wrap {
position: relative;
flex: 1;
min-width: 0;
}
.wt-var-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.wt-var-tag {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 6px 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
}
.wt-var-tag.categorical { background: #f3e8ff; color: #7c3aed; border: 1px solid #ddd6fe; }
.wt-var-tag.numeric { background: #dbeafe; color: #1d4ed8; border: 1px solid #bfdbfe; }
.wt-var-tag.datetime { background: #ffedd5; color: #c2410c; border: 1px solid #fed7aa; }
.wt-var-tag.text { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }
.wt-var-tag.unknown { background: #f1f5f9; color: #64748b; border: 1px solid #e2e8f0; }
.wt-var-tag.warn { border-color: #fbbf24; background: #fffbeb; }
.wt-var-tag-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 50%;
opacity: 0.6;
padding: 0;
}
.wt-var-tag-remove:hover { opacity: 1; background: rgba(0,0,0,0.1); }
.wt-var-add-btn {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border: 1px dashed #cbd5e1;
border-radius: 4px;
background: transparent;
cursor: pointer;
font-size: 11px;
color: #64748b;
}
.wt-var-add-btn:hover { border-color: #3b82f6; color: #3b82f6; background: #eff6ff; }
/* ── Mismatch Dialog (Layer 3) ── */
.wt-mismatch-overlay {
position: absolute;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.4);
backdrop-filter: blur(2px);
}
.wt-mismatch-dialog {
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0,0,0,0.2);
max-width: 500px;
width: 90%;
padding: 24px;
}
.wt-mismatch-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
color: #dc2626;
}
.wt-mismatch-header h3 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #1e293b;
}
.wt-mismatch-desc {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0 0 16px;
}
.wt-mismatch-list {
max-height: 200px;
overflow-y: auto;
margin-bottom: 20px;
}
.wt-mismatch-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
background: #fef2f2;
border-radius: 6px;
margin-bottom: 6px;
font-size: 12px;
}
.wt-mismatch-step { font-weight: 600; color: #1e293b; }
.wt-mismatch-param { color: #475569; }
.wt-mismatch-msg { color: #dc2626; }
.wt-mismatch-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.wt-mismatch-cancel {
padding: 8px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
font-size: 13px;
cursor: pointer;
color: #475569;
}
.wt-mismatch-cancel:hover { background: #f1f5f9; }
.wt-mismatch-force {
padding: 8px 16px;
border: 1px solid #fca5a5;
border-radius: 8px;
background: #fef2f2;
font-size: 13px;
cursor: pointer;
color: #dc2626;
font-weight: 500;
}
.wt-mismatch-force:hover { background: #fee2e2; }

View File

@@ -77,8 +77,8 @@ function blockToDocxElements(block: ReportBlock, index: number): (Paragraph | Ta
case 'table': {
const headers = block.headers ?? [];
const rows = block.rows ?? [];
if (headers.length > 0 || rows.length > 0) {
const rawRows = block.rows ?? [];
if (headers.length > 0 || (Array.isArray(rawRows) && rawRows.length > 0)) {
if (block.title) {
elements.push(
new Paragraph({
@@ -91,8 +91,15 @@ function blockToDocxElements(block: ReportBlock, index: number): (Paragraph | Ta
if (headers.length > 0) {
tableRows.push(makeRow(headers.map(String), true));
}
for (const row of rows) {
tableRows.push(makeRow(row.map(c => (c === null || c === undefined ? '-' : String(c)))));
const normalizedRows = (Array.isArray(rawRows) ? rawRows : []).map((row: any) => {
if (Array.isArray(row)) return row;
if (row && typeof row === 'object') {
return headers.length > 0 ? headers.map((h: string) => row[h] ?? '') : Object.values(row);
}
return [String(row ?? '')];
});
for (const row of normalizedRows) {
tableRows.push(makeRow(row.map((c: any) => (c === null || c === undefined ? '-' : String(c)))));
}
elements.push(
new Table({