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:
@@ -1018,4 +1018,4 @@
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user