feat(web): add project workflow settings wizard and replace free switcher.
Topbar shows configure/view workflow actions under governance mode with a multi-step ProjectSettingsModal for plan, duty assignment, and confirmed activation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
be5cdb1ef6
commit
1fa6e9aa77
+42
-4
@@ -52,6 +52,8 @@ import { AdminSettingsModal } from './components/admin/AdminSettingsModal';
|
||||
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
|
||||
import { CreateProjectModal } from './components/project/CreateProjectModal';
|
||||
import { ProjectMembersModal } from './components/project/ProjectMembersModal';
|
||||
import { ProjectSettingsModal } from './components/project/ProjectSettingsModal';
|
||||
import { fetchProjectSettings } from './api/projectSettings';
|
||||
import { FilePreviewPanel } from './components/doc/preview/FilePreviewPanel';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { LoginView } from './components/auth/LoginView';
|
||||
@@ -590,6 +592,8 @@ export default function App() {
|
||||
const [sessionSyncNotice, setSessionSyncNotice] = useState<string | null>(null);
|
||||
const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false);
|
||||
const [projectMembersError, setProjectMembersError] = useState<string | null>(null);
|
||||
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
|
||||
const [workflowGovernanceEnabled, setWorkflowGovernanceEnabled] = useState(false);
|
||||
|
||||
const isAdmin = currentUser.roleId === 'role-admin';
|
||||
|
||||
@@ -809,16 +813,17 @@ export default function App() {
|
||||
|
||||
// 项目多工作流激活状态与文档血缘 (支持前端切换与状态联动)
|
||||
const [projectWorkflowsState, setProjectWorkflowsState] = useState<
|
||||
Record<number, { activeKey: string; version: number; creatorId: number }>
|
||||
Record<number, { activeKey: string; version: number; creatorId: number; lifecycleStatus: string }>
|
||||
>({});
|
||||
const [projectLineages, setProjectLineages] = useState<DocumentLineage[]>([]);
|
||||
|
||||
const currentActiveSession =
|
||||
sessions.find((s) => s.sessionId === activeSessionId) || sessions[0];
|
||||
const currentWfState = projectWorkflowsState[currentProjectId] || {
|
||||
activeKey: 'main',
|
||||
activeKey: '',
|
||||
version: 1,
|
||||
creatorId: currentActiveSession?.userId || currentUser.id || 2,
|
||||
lifecycleStatus: 'planning',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -829,14 +834,18 @@ export default function App() {
|
||||
setProjectWorkflowsState((prev) => ({
|
||||
...prev,
|
||||
[currentProjectId]: {
|
||||
activeKey: p.active_workflow_key || 'main',
|
||||
activeKey: p.active_workflow_key || '',
|
||||
version: p.workflow_selection_version || 1,
|
||||
creatorId: p.creator_user_id || 1,
|
||||
lifecycleStatus: p.lifecycle_status || (p.active_workflow_key ? 'active' : 'planning'),
|
||||
},
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
fetchProjectSettings(currentProjectId)
|
||||
.then(() => setWorkflowGovernanceEnabled(true))
|
||||
.catch(() => setWorkflowGovernanceEnabled(false));
|
||||
}
|
||||
}, [currentProjectId]);
|
||||
|
||||
@@ -3343,11 +3352,19 @@ export default function App() {
|
||||
onOpenProjectMembers={() => setProjectMembersModalOpen(true)}
|
||||
activeWorkflowKey={currentWfState.activeKey}
|
||||
workflowVersion={currentWfState.version}
|
||||
lifecycleStatus={currentWfState.lifecycleStatus}
|
||||
isCreator={currentWfState.creatorId === currentUser.id}
|
||||
governanceEnabled={workflowGovernanceEnabled}
|
||||
onOpenProjectSettings={() => setProjectSettingsOpen(true)}
|
||||
onWorkflowChanged={(newKey, newVer) => {
|
||||
setProjectWorkflowsState((prev) => ({
|
||||
...prev,
|
||||
[currentProjectId]: { ...currentWfState, activeKey: newKey, version: newVer },
|
||||
[currentProjectId]: {
|
||||
...currentWfState,
|
||||
activeKey: newKey,
|
||||
version: newVer,
|
||||
lifecycleStatus: 'active',
|
||||
},
|
||||
}));
|
||||
}}
|
||||
workflowBanner={workflowBanner}
|
||||
@@ -3810,6 +3827,27 @@ export default function App() {
|
||||
onRemoveMember={handleRemoveProjectMember}
|
||||
onRestoreMember={handleRestoreProjectMember}
|
||||
/>
|
||||
|
||||
<ProjectSettingsModal
|
||||
open={projectSettingsOpen}
|
||||
projectId={currentProjectId}
|
||||
projectName={currentActiveSession?.title || '当前项目'}
|
||||
members={(currentProject?.members || []).map((m) => ({
|
||||
userId: m.userId,
|
||||
name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`,
|
||||
}))}
|
||||
onClose={() => setProjectSettingsOpen(false)}
|
||||
onActivated={(activeKey) => {
|
||||
setProjectWorkflowsState((prev) => ({
|
||||
...prev,
|
||||
[currentProjectId]: {
|
||||
...currentWfState,
|
||||
activeKey,
|
||||
lifecycleStatus: 'active',
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ProjectSettings,
|
||||
WorkflowPlan,
|
||||
ActivationPreview,
|
||||
ActivateResult,
|
||||
} from '../types/projectSettings';
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
export async function fetchDefaultWorkflowPlan(
|
||||
projectId: number,
|
||||
workflowKey: string,
|
||||
version?: number
|
||||
): Promise<WorkflowPlan> {
|
||||
const params = new URLSearchParams({ workflow_key: workflowKey });
|
||||
if (version) params.set('version', String(version));
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/projects/${projectId}/workflow-plan/default?${params}`,
|
||||
{ headers: getBearerAuthHeaders() }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(parseError(text) || `加载默认规划失败: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchProjectSettings(projectId: number): Promise<ProjectSettings> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/settings`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(parseError(text) || `获取项目设置失败: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function saveWorkflowPlan(
|
||||
projectId: number,
|
||||
plan: WorkflowPlan,
|
||||
expectedPlanRevision: number,
|
||||
expectedSelectionVersion: number
|
||||
): Promise<ProjectSettings> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-plan`, {
|
||||
method: 'PUT',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
expected_plan_revision: expectedPlanRevision,
|
||||
expected_selection_version: expectedSelectionVersion,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(parseError(text) || `保存工作流规划失败: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchActivationPreview(projectId: number): Promise<ActivationPreview> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/activation-preview`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: '{}',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(parseError(text) || `激活预览失败: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function activateWorkflowInstance(
|
||||
projectId: number,
|
||||
input: {
|
||||
confirmation_token: string;
|
||||
expected_project_version: number;
|
||||
expected_plan_revision: number;
|
||||
}
|
||||
): Promise<ActivateResult> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-instances/activate`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(parseError(text) || `激活工作流失败: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function parseError(text: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return parsed.message || parsed.error || null;
|
||||
} catch {
|
||||
return text || null;
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,11 @@ interface AppShellProps {
|
||||
docContent: React.ReactNode;
|
||||
activeWorkflowKey?: string;
|
||||
workflowVersion?: number;
|
||||
lifecycleStatus?: string;
|
||||
isCreator?: boolean;
|
||||
governanceEnabled?: boolean;
|
||||
onWorkflowChanged?: (newKey: string, newVersion: number) => void;
|
||||
onOpenProjectSettings?: () => void;
|
||||
workflowBanner?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -48,8 +51,11 @@ export const AppShell: React.FC<AppShellProps> = ({
|
||||
docContent,
|
||||
activeWorkflowKey,
|
||||
workflowVersion,
|
||||
lifecycleStatus,
|
||||
isCreator,
|
||||
governanceEnabled,
|
||||
onWorkflowChanged,
|
||||
onOpenProjectSettings,
|
||||
workflowBanner,
|
||||
}) => {
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||
@@ -123,8 +129,11 @@ export const AppShell: React.FC<AppShellProps> = ({
|
||||
onToggleRight={() => setRightCollapsed(!rightCollapsed)}
|
||||
activeWorkflowKey={activeWorkflowKey}
|
||||
workflowVersion={workflowVersion}
|
||||
lifecycleStatus={lifecycleStatus}
|
||||
isCreator={isCreator}
|
||||
governanceEnabled={governanceEnabled}
|
||||
onWorkflowChanged={onWorkflowChanged}
|
||||
onOpenProjectSettings={onOpenProjectSettings}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -13,8 +13,11 @@ interface TopbarProps {
|
||||
onToggleRight: () => void;
|
||||
activeWorkflowKey?: string;
|
||||
workflowVersion?: number;
|
||||
lifecycleStatus?: string;
|
||||
isCreator?: boolean;
|
||||
governanceEnabled?: boolean;
|
||||
onWorkflowChanged?: (newKey: string, newVersion: number) => void;
|
||||
onOpenProjectSettings?: () => void;
|
||||
}
|
||||
|
||||
export const Topbar: React.FC<TopbarProps> = ({
|
||||
@@ -27,8 +30,11 @@ export const Topbar: React.FC<TopbarProps> = ({
|
||||
onToggleRight,
|
||||
activeWorkflowKey = 'main',
|
||||
workflowVersion = 1,
|
||||
lifecycleStatus = 'active',
|
||||
isCreator = true,
|
||||
governanceEnabled = false,
|
||||
onWorkflowChanged,
|
||||
onOpenProjectSettings,
|
||||
}) => {
|
||||
return (
|
||||
<header
|
||||
@@ -123,8 +129,11 @@ export const Topbar: React.FC<TopbarProps> = ({
|
||||
projectId={currentSession.projectId}
|
||||
activeWorkflowKey={activeWorkflowKey}
|
||||
workflowVersion={workflowVersion}
|
||||
lifecycleStatus={lifecycleStatus}
|
||||
isCreator={isCreator}
|
||||
governanceEnabled={governanceEnabled}
|
||||
onWorkflowChanged={onWorkflowChanged}
|
||||
onOpenSettings={onOpenProjectSettings}
|
||||
/>
|
||||
|
||||
{/* 项目助手提示 */}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { X, ChevronRight, ChevronLeft, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
fetchProjectSettings,
|
||||
fetchDefaultWorkflowPlan,
|
||||
saveWorkflowPlan,
|
||||
fetchActivationPreview,
|
||||
activateWorkflowInstance,
|
||||
} from '../../api/projectSettings';
|
||||
import {
|
||||
ProjectSettings,
|
||||
WorkflowPlan,
|
||||
ActivationPreview,
|
||||
DocTaskPlan,
|
||||
} from '../../types/projectSettings';
|
||||
|
||||
interface ProjectMemberOption {
|
||||
userId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProjectSettingsModalProps {
|
||||
open: boolean;
|
||||
projectId: number;
|
||||
projectName: string;
|
||||
members: ProjectMemberOption[];
|
||||
onClose: () => void;
|
||||
onActivated?: (activeWorkflowKey: string) => void;
|
||||
}
|
||||
|
||||
type Step = 'select' | 'configure' | 'confirm';
|
||||
|
||||
export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
|
||||
open,
|
||||
projectId,
|
||||
projectName,
|
||||
members,
|
||||
onClose,
|
||||
onActivated,
|
||||
}) => {
|
||||
const [step, setStep] = useState<Step>('select');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [settings, setSettings] = useState<ProjectSettings | null>(null);
|
||||
const [plan, setPlan] = useState<WorkflowPlan | null>(null);
|
||||
const [preview, setPreview] = useState<ActivationPreview | null>(null);
|
||||
const [selectedKey, setSelectedKey] = useState('');
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const s = await fetchProjectSettings(projectId);
|
||||
setSettings(s);
|
||||
if (s.plan) {
|
||||
setPlan(s.plan);
|
||||
setSelectedKey(s.plan.workflow_definition_key);
|
||||
setStep('configure');
|
||||
} else if (s.published_workflows?.length) {
|
||||
setSelectedKey(s.published_workflows[0].definition_key);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '加载项目设置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStep('select');
|
||||
setPreview(null);
|
||||
loadSettings();
|
||||
}
|
||||
}, [open, loadSettings]);
|
||||
|
||||
const handleSelectWorkflow = async () => {
|
||||
if (!settings || !selectedKey) return;
|
||||
const wf = settings.published_workflows?.find((w) => w.definition_key === selectedKey);
|
||||
if (!wf) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const defaultPlan = await fetchDefaultWorkflowPlan(projectId, wf.definition_key, wf.latest_version);
|
||||
setPlan(defaultPlan);
|
||||
setStep('configure');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '加载默认规划失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDocTask = (stageIdx: number, taskIdx: number, patch: Partial<DocTaskPlan>) => {
|
||||
if (!plan) return;
|
||||
const stages = plan.stages.map((st, si) => {
|
||||
if (si !== stageIdx) return st;
|
||||
return {
|
||||
...st,
|
||||
doc_tasks: st.doc_tasks.map((dt, ti) => (ti === taskIdx ? { ...dt, ...patch } : dt)),
|
||||
};
|
||||
});
|
||||
setPlan({ ...plan, stages });
|
||||
};
|
||||
|
||||
const handleSavePlan = async () => {
|
||||
if (!plan || !settings) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await saveWorkflowPlan(
|
||||
projectId,
|
||||
plan,
|
||||
settings.workflow_plan_revision,
|
||||
settings.workflow_selection_version
|
||||
);
|
||||
setSettings(updated);
|
||||
if (updated.plan) setPlan(updated.plan);
|
||||
const pv = await fetchActivationPreview(projectId);
|
||||
setPreview(pv);
|
||||
setStep('confirm');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '保存规划失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async () => {
|
||||
if (!preview || !settings) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await activateWorkflowInstance(projectId, {
|
||||
confirmation_token: preview.confirmation_token,
|
||||
expected_project_version: preview.expected_project_version,
|
||||
expected_plan_revision: preview.expected_plan_revision,
|
||||
});
|
||||
onActivated?.(result.active_workflow_key);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '激活失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
background: 'rgba(15, 23, 42, 0.45)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 'min(720px, 94vw)',
|
||||
maxHeight: '88vh',
|
||||
background: 'var(--bg-card, #fff)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.18)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 700 }}>项目工作流配置</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>{projectName}</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer' }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 20px', display: 'flex', gap: '8px', fontSize: '12px' }}>
|
||||
{(['select', 'configure', 'confirm'] as Step[]).map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: '999px',
|
||||
background: step === s ? 'var(--brand-50)' : 'var(--bg-subtle)',
|
||||
color: step === s ? 'var(--brand-600)' : 'var(--text-muted)',
|
||||
fontWeight: step === s ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{i + 1}. {s === 'select' ? '选择工作流' : s === 'configure' ? '配置职责' : '确认激活'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
|
||||
{loading && !settings && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-muted)' }}>
|
||||
<Loader2 size={16} className="spin" /> 加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: 'var(--danger-50, #fef2f2)',
|
||||
border: '1px solid var(--danger-200)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--danger-700)',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'select' && settings && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text-muted)', margin: 0 }}>
|
||||
选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。
|
||||
</p>
|
||||
{(settings.published_workflows || []).map((wf) => (
|
||||
<label
|
||||
key={wf.definition_key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '12px',
|
||||
border: `1px solid ${selectedKey === wf.definition_key ? 'var(--brand-400)' : 'var(--border)'}`,
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
background: selectedKey === wf.definition_key ? 'var(--brand-50)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
checked={selectedKey === wf.definition_key}
|
||||
onChange={() => setSelectedKey(wf.definition_key)}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px' }}>{wf.definition_name}</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
|
||||
{wf.definition_key} · v{wf.latest_version}
|
||||
{wf.product_domain ? ` · ${wf.product_domain}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
{(!settings.published_workflows || settings.published_workflows.length === 0) && (
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
|
||||
暂无已发布工作流,请先在系统设置中发布工作流定义。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'configure' && plan && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text-muted)', margin: 0 }}>
|
||||
为启用的文档任务指派作者、审核人、批准人(三权分离)。
|
||||
</p>
|
||||
{plan.stages.length === 0 && (
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-muted)' }}>
|
||||
请先保存一次规划以从工作流定义加载阶段结构,或返回上一步重新选择。
|
||||
</div>
|
||||
)}
|
||||
{plan.stages.map((st, si) => (
|
||||
<div key={st.stage_key} style={{ border: '1px solid var(--border)', borderRadius: '8px', padding: '12px' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>{st.stage_key}</div>
|
||||
{st.doc_tasks.map((dt, ti) => (
|
||||
<div key={dt.task_key} style={{ marginBottom: '10px', fontSize: '12px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dt.enabled}
|
||||
onChange={(e) => updateDocTask(si, ti, { enabled: e.target.checked })}
|
||||
/>
|
||||
{dt.task_key}
|
||||
</label>
|
||||
{dt.enabled && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
|
||||
{(['author', 'reviewer', 'approver'] as const).map((role) => (
|
||||
<select
|
||||
key={role}
|
||||
value={dt.duties[`${role}_user_id` as keyof typeof dt.duties] || ''}
|
||||
onChange={(e) =>
|
||||
updateDocTask(si, ti, {
|
||||
duties: {
|
||||
...dt.duties,
|
||||
[`${role}_user_id`]: Number(e.target.value) || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
style={{ fontSize: '11px', padding: '4px' }}
|
||||
>
|
||||
<option value="">{role === 'author' ? '作者' : role === 'reviewer' ? '审核' : '批准'}</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.userId} value={m.userId}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && preview && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '14px',
|
||||
background: 'var(--bg-subtle)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>
|
||||
<CheckCircle2 size={14} style={{ display: 'inline', marginRight: '6px' }} />
|
||||
激活预览
|
||||
</div>
|
||||
<div>工作流:{preview.summary.workflow_name} v{preview.summary.workflow_version}</div>
|
||||
<div>启用阶段:{preview.summary.enabled_stages} · 文档任务:{preview.summary.enabled_doc_tasks}</div>
|
||||
<div>职责指派:{preview.summary.assigned_duties} 项</div>
|
||||
{preview.summary.first_stage_name && (
|
||||
<div>首阶段:{preview.summary.first_stage_name}</div>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text-muted)', margin: 0 }}>
|
||||
确认后将锁定工作流配置,运行期间不可直接修改开关与职责。令牌有效期至{' '}
|
||||
{new Date(preview.expires_at).toLocaleTimeString()}。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 20px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (step === 'configure') setStep('select');
|
||||
else if (step === 'confirm') setStep('configure');
|
||||
else onClose();
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-subtle)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
{step === 'select' ? '取消' : '上一步'}
|
||||
</button>
|
||||
|
||||
{step === 'select' && (
|
||||
<button
|
||||
onClick={handleSelectWorkflow}
|
||||
disabled={!selectedKey || loading}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'var(--brand-600, #4f46e5)',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
opacity: !selectedKey ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
下一步 <ChevronRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step === 'configure' && (
|
||||
<button
|
||||
onClick={handleSavePlan}
|
||||
disabled={loading || !plan}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'var(--brand-600, #4f46e5)',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 size={14} /> : null}
|
||||
校验并预览 <ChevronRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<button
|
||||
onClick={handleActivate}
|
||||
disabled={loading}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'var(--success, #16a34a)',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 size={14} /> : <CheckCircle2 size={14} />}
|
||||
确认激活
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Workflow, Lock, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Workflow, Lock, CheckCircle2, AlertCircle, Settings2, Eye } from 'lucide-react';
|
||||
import { activateWorkflow, getProject } from '../../api/projectWorkflow';
|
||||
|
||||
interface ProjectWorkflowSwitcherProps {
|
||||
projectId: number;
|
||||
activeWorkflowKey: string;
|
||||
workflowVersion: number;
|
||||
lifecycleStatus?: string;
|
||||
isCreator: boolean;
|
||||
governanceEnabled?: boolean;
|
||||
onWorkflowChanged?: (newKey: string, newVersion: number) => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
const WORKFLOW_OPTIONS = [
|
||||
@@ -16,17 +19,26 @@ const WORKFLOW_OPTIONS = [
|
||||
{ key: 'test_manager', label: '智能测试经理', icon: '🧪' },
|
||||
];
|
||||
|
||||
const workflowLabel = (key: string) =>
|
||||
WORKFLOW_OPTIONS.find((w) => w.key === key)?.label || key;
|
||||
|
||||
export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = ({
|
||||
projectId,
|
||||
activeWorkflowKey,
|
||||
workflowVersion,
|
||||
lifecycleStatus = 'active',
|
||||
isCreator,
|
||||
governanceEnabled = false,
|
||||
onWorkflowChanged,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const handleSelect = async (targetKey: string) => {
|
||||
const isPlanning = lifecycleStatus === 'planning' || !activeWorkflowKey;
|
||||
const showGovernanceUI = governanceEnabled || isPlanning;
|
||||
|
||||
const handleLegacySelect = async (targetKey: string) => {
|
||||
if (targetKey === activeWorkflowKey) return;
|
||||
if (!isCreator) {
|
||||
setErrorMsg('权限不足:仅项目创建人可切换激活工作流');
|
||||
@@ -44,8 +56,7 @@ export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = (
|
||||
expected_version: workflowVersion,
|
||||
reason: '前端控制台用户切换工作流',
|
||||
});
|
||||
} catch (firstErr: any) {
|
||||
// 若遇到并发版本落后或冲突,自动拉取数据库最新项目版本重试一次
|
||||
} catch {
|
||||
const fresh = await getProject(projectId);
|
||||
res = await activateWorkflow(projectId, {
|
||||
workflow_key: targetKey,
|
||||
@@ -56,14 +67,109 @@ export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = (
|
||||
if (onWorkflowChanged && res) {
|
||||
onWorkflowChanged(res.active_workflow_key, res.version);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || '切换工作流失败');
|
||||
} catch (err: unknown) {
|
||||
setErrorMsg(err instanceof Error ? err.message : '切换工作流失败');
|
||||
setTimeout(() => setErrorMsg(null), 4000);
|
||||
} finally {
|
||||
setSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (showGovernanceUI) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 10px',
|
||||
background: 'var(--bg-subtle, #f1f5f9)',
|
||||
borderRadius: 'var(--r-md, 8px)',
|
||||
border: '1px solid var(--border, #e2e8f0)',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
<Workflow size={13} />
|
||||
{isPlanning ? (
|
||||
<span style={{ color: 'var(--amber-600, #d97706)', fontWeight: 600 }}>规划态 · 未激活工作流</span>
|
||||
) : (
|
||||
<span style={{ fontWeight: 600, color: 'var(--brand-600)' }}>
|
||||
{workflowLabel(activeWorkflowKey)}
|
||||
<CheckCircle2 size={12} style={{ marginLeft: '4px', display: 'inline' }} />
|
||||
</span>
|
||||
)}
|
||||
{isPlanning && isCreator && onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '3px 8px',
|
||||
marginLeft: '4px',
|
||||
fontSize: '11px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: 'var(--brand-600, #4f46e5)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<Settings2 size={12} />
|
||||
配置工作流
|
||||
</button>
|
||||
)}
|
||||
{!isPlanning && onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '3px 8px',
|
||||
marginLeft: '4px',
|
||||
fontSize: '11px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--border)',
|
||||
cursor: 'pointer',
|
||||
background: 'var(--bg-card, #fff)',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
<Eye size={12} />
|
||||
查看流程
|
||||
</button>
|
||||
)}
|
||||
{!isCreator && isPlanning && <Lock size={12} style={{ opacity: 0.5 }} />}
|
||||
</div>
|
||||
{errorMsg && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '36px',
|
||||
left: '0',
|
||||
zIndex: 100,
|
||||
background: 'var(--danger-50, #fef2f2)',
|
||||
border: '1px solid var(--danger-200, #fecaca)',
|
||||
color: 'var(--danger-700, #b91c1c)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={12} />
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', position: 'relative' }}>
|
||||
<div
|
||||
@@ -97,7 +203,7 @@ export const ProjectWorkflowSwitcher: React.FC<ProjectWorkflowSwitcherProps> = (
|
||||
return (
|
||||
<button
|
||||
key={wf.key}
|
||||
onClick={() => handleSelect(wf.key)}
|
||||
onClick={() => handleLegacySelect(wf.key)}
|
||||
disabled={switching || (!isCreator && !isActive)}
|
||||
title={
|
||||
!isCreator
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
export interface DocDuty {
|
||||
author_user_id?: number;
|
||||
reviewer_user_id?: number;
|
||||
approver_user_id?: number;
|
||||
}
|
||||
|
||||
export interface DocTaskPlan {
|
||||
task_key: string;
|
||||
enabled: boolean;
|
||||
duties: DocDuty;
|
||||
}
|
||||
|
||||
export interface StagePlan {
|
||||
stage_key: string;
|
||||
enabled: boolean;
|
||||
doc_tasks: DocTaskPlan[];
|
||||
}
|
||||
|
||||
export interface WorkflowPlan {
|
||||
workflow_definition_key: string;
|
||||
workflow_version: number;
|
||||
workflow_version_id?: number;
|
||||
stages: StagePlan[];
|
||||
}
|
||||
|
||||
export interface PublishedOption {
|
||||
definition_key: string;
|
||||
definition_name: string;
|
||||
latest_version: number;
|
||||
workflow_version_id: number;
|
||||
product_domain?: string;
|
||||
}
|
||||
|
||||
export interface ProjectSettings {
|
||||
project_id: number;
|
||||
lifecycle_status: string;
|
||||
active_workflow_key: string;
|
||||
workflow_selection_version: number;
|
||||
workflow_plan_revision: number;
|
||||
plan?: WorkflowPlan;
|
||||
can_edit_plan: boolean;
|
||||
can_activate: boolean;
|
||||
published_workflows?: PublishedOption[];
|
||||
}
|
||||
|
||||
export interface ActivationPreview {
|
||||
confirmation_token: string;
|
||||
plan_hash: string;
|
||||
expected_project_version: number;
|
||||
expected_plan_revision: number;
|
||||
summary: {
|
||||
workflow_name: string;
|
||||
workflow_version: number;
|
||||
enabled_stages: number;
|
||||
enabled_doc_tasks: number;
|
||||
assigned_duties: number;
|
||||
first_stage_key?: string;
|
||||
first_stage_name?: string;
|
||||
};
|
||||
validation: { valid: boolean; issues: Array<{ code: string; message: string }> };
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface ActivateResult {
|
||||
project_id: number;
|
||||
lifecycle_status: string;
|
||||
active_workflow_key: string;
|
||||
}
|
||||
@@ -12,8 +12,10 @@ export interface Project {
|
||||
intended_use?: string;
|
||||
target_market?: string;
|
||||
creator_user_id: number;
|
||||
lifecycle_status?: string;
|
||||
active_workflow_key: string;
|
||||
workflow_selection_version: number;
|
||||
workflow_plan_revision?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user