feat: align frontend document and rule management flows
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node';
|
||||
import { Link, useLoaderData } from '@remix-run/react';
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node';
|
||||
import { Link, useFetcher, useLoaderData, useRevalidator } from '@remix-run/react';
|
||||
import type React from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { Table } from '~/components/ui/Table';
|
||||
import { Tag, type TagColor } from '~/components/ui/Tag';
|
||||
import { loadRuleYamlPack, loadRuleYamlPacks, type RuleSummary, type RuleYamlPack } from '~/utils/rules-yaml-mock.server';
|
||||
import { buildRuleYamlPreview, collectDependencyOptions, getDefaultExpandedDependencyGroups, type DependencyOption, type EditableRuleConfig, type ValidationIssue } from '~/utils/rules-config-editor';
|
||||
import { getUserSession } from '~/api/login/auth.server';
|
||||
import { API_BASE_URL } from '~/config/api-config';
|
||||
import { loadRuleConfigPack, loadRuleConfigPacks, loadRuleConfigVersions, type RuleVersionItem } from '~/utils/rules-config-packs.server';
|
||||
import { buildRuleYamlPreview, buildYamlPreview, collectDependencyOptions, getDefaultExpandedDependencyGroups, validateEditableRuleConfig, type DependencyOption, type EditableRuleConfig, type ValidationIssue } from '~/utils/rules-config-editor';
|
||||
import type { RuleSummary, RuleYamlPack } from '~/utils/rules-yaml-mock.server';
|
||||
import styles from '~/styles/pages/rules_test.css?url';
|
||||
|
||||
export const links = () => [
|
||||
@@ -21,6 +24,15 @@ export const meta: MetaFunction = () => [
|
||||
type LoaderData = {
|
||||
pack: RuleYamlPack;
|
||||
requestedRuleId: string;
|
||||
versions: RuleVersionItem[];
|
||||
};
|
||||
|
||||
type ActionData = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
intent: 'save' | 'publish' | 'rollback';
|
||||
versionId?: number;
|
||||
versionNo?: string;
|
||||
};
|
||||
|
||||
type EditorState = { kind: 'rule'; mode: 'create' | 'edit'; id?: string } | null;
|
||||
@@ -304,18 +316,96 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const packId = url.searchParams.get('packId') || url.searchParams.get('id') || '';
|
||||
const requestedRuleId = url.searchParams.get('ruleId') || '';
|
||||
const packs = await loadRuleYamlPacks();
|
||||
const pack = (packId ? await loadRuleYamlPack(packId) : undefined) || packs[0];
|
||||
const packs = await loadRuleConfigPacks(request);
|
||||
const pack = (packId ? await loadRuleConfigPack(request, packId) : undefined) || packs[0];
|
||||
|
||||
if (!pack) {
|
||||
throw new Response('未找到 YAML 配置', { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json({ pack, requestedRuleId } satisfies LoaderData);
|
||||
const versions = await loadRuleConfigVersions(request, pack.metadata.typeId || '');
|
||||
|
||||
return Response.json({ pack, requestedRuleId, versions } satisfies LoaderData);
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const { frontendJWT, userInfo } = await getUserSession(request);
|
||||
if (!frontendJWT) {
|
||||
return json<ActionData>({ success: false, intent: 'save', message: '登录已失效,请重新登录后再保存。' }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const intent = String(formData.get('intent') || 'save').trim() as ActionData['intent'];
|
||||
const ruleType = String(formData.get('ruleType') || '').trim();
|
||||
const yamlText = String(formData.get('yamlText') || '');
|
||||
const changeNote = String(formData.get('changeNote') || '').trim() || 'rulesTest.detail 保存评查点草稿';
|
||||
const versionId = Number(formData.get('versionId') || 0);
|
||||
|
||||
if (!ruleType) {
|
||||
return json<ActionData>({ success: false, intent, message: '当前规则类型缺失,无法保存。' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (intent === 'save' && !yamlText.trim()) {
|
||||
return json<ActionData>({ success: false, intent, message: '当前 YAML 内容为空,无法保存。' }, { status: 400 });
|
||||
}
|
||||
|
||||
if ((intent === 'publish' || intent === 'rollback') && (!Number.isFinite(versionId) || versionId <= 0)) {
|
||||
return json<ActionData>({ success: false, intent, message: '目标版本缺失,无法继续操作。' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const apiPath = intent === 'save'
|
||||
? `/api/rule-sets/${encodeURIComponent(ruleType)}/versions`
|
||||
: `/api/rule-sets/${encodeURIComponent(ruleType)}/${intent}`;
|
||||
const requestBody = intent === 'save'
|
||||
? {
|
||||
yamlText,
|
||||
changeNote,
|
||||
editorUserId: userInfo?.user_id ? Number(userInfo.user_id) : undefined,
|
||||
}
|
||||
: {
|
||||
versionId,
|
||||
operatorUserId: userInfo?.user_id ? Number(userInfo.user_id) : undefined,
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${apiPath}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${frontendJWT}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
const data = payload?.data ?? null;
|
||||
const message = String(payload?.message || payload?.msg || (response.ok ? '规则草稿保存成功' : '规则草稿保存失败'));
|
||||
|
||||
if (!response.ok || !data) {
|
||||
return json<ActionData>({ success: false, intent, message }, { status: response.status || 500 });
|
||||
}
|
||||
|
||||
return json<ActionData>({
|
||||
success: true,
|
||||
intent,
|
||||
message,
|
||||
versionId: Number(data.id),
|
||||
versionNo: String(data.versionNo || ''),
|
||||
});
|
||||
} catch (error) {
|
||||
return json<ActionData>({
|
||||
success: false,
|
||||
intent,
|
||||
message: error instanceof Error ? error.message : '规则草稿保存失败',
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export default function RulesTestDetail() {
|
||||
const { pack, requestedRuleId } = useLoaderData<typeof loader>() as LoaderData;
|
||||
const { pack, requestedRuleId, versions } = useLoaderData<typeof loader>() as LoaderData;
|
||||
const saveFetcher = useFetcher<ActionData>();
|
||||
const revalidator = useRevalidator();
|
||||
const initialRuleKey = requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' });
|
||||
const [rules, setRules] = useState<RuleSummary[]>(pack.rules);
|
||||
const [selectedRuleKey, setSelectedRuleKey] = useState(initialRuleKey);
|
||||
@@ -328,6 +418,8 @@ export default function RulesTestDetail() {
|
||||
const [showValidation, setShowValidation] = useState(false);
|
||||
const [showYamlPreview, setShowYamlPreview] = useState(false);
|
||||
const [draftSaved, setDraftSaved] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const promptEditorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -341,6 +433,8 @@ export default function RulesTestDetail() {
|
||||
setShowValidation(false);
|
||||
setShowYamlPreview(false);
|
||||
setDraftSaved(false);
|
||||
setSaveMessage('');
|
||||
setSaveError('');
|
||||
}, [pack.id, requestedRuleId]);
|
||||
|
||||
const currentRule = useMemo(() => {
|
||||
@@ -409,9 +503,53 @@ export default function RulesTestDetail() {
|
||||
}, [dialogDependencyOptions, dependencySelection]);
|
||||
const dependencyDialogEmptyText = dependencySearch.trim() ? '没有匹配的字段。' : '当前文档类型暂无可追加字段。';
|
||||
const hasErrors = validationIssues.some(issue => issue.severity === 'error');
|
||||
const configValidationIssues = useMemo(() => validateEditableRuleConfig(editableConfig), [editableConfig]);
|
||||
const hasConfigErrors = configValidationIssues.some(issue => issue.severity === 'error');
|
||||
const fullYamlText = useMemo(() => buildYamlPreview(editableConfig), [editableConfig]);
|
||||
const isSmartRuleDraft = ruleDraft.type === 'ai_rule' || ruleDraft.checkTypes.includes('ai');
|
||||
const isRuleGroupDraft = ruleDraft.type === 'rule_group';
|
||||
const rulesById = useMemo(() => new Map(rules.map(rule => [rule.ruleId || rule.id, rule])), [rules]);
|
||||
const saveButtonBusy = saveFetcher.state !== 'idle';
|
||||
const latestDraftVersion = useMemo(
|
||||
() => versions.find((item) => !['published', 'rollback'].includes(item.status)),
|
||||
[versions],
|
||||
);
|
||||
const rollbackTargetVersion = useMemo(
|
||||
() => versions.find((item) => ['published', 'rollback'].includes(item.status) && item.id !== pack.currentVersionId),
|
||||
[versions, pack.currentVersionId],
|
||||
);
|
||||
const currentResolvedVersion = useMemo(
|
||||
() => versions.find((item) => item.id === pack.currentVersionId || item.id === pack.fallbackVersionId) || null,
|
||||
[pack.currentVersionId, pack.fallbackVersionId, versions],
|
||||
);
|
||||
const versionStatusLabel = (status: string | undefined) => {
|
||||
const normalized = String(status || '').trim().toLowerCase();
|
||||
if (normalized === 'published') return '已发布';
|
||||
if (normalized === 'rollback') return '回滚版本';
|
||||
if (normalized === 'draft') return '草稿';
|
||||
if (normalized === 'deprecated') return '已废弃';
|
||||
return status || '-';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveFetcher.data) return;
|
||||
if (saveFetcher.data.success) {
|
||||
setDraftSaved(saveFetcher.data.intent === 'save');
|
||||
setSaveError('');
|
||||
if (saveFetcher.data.intent === 'save') {
|
||||
setSaveMessage(saveFetcher.data.versionNo
|
||||
? `规则草稿已保存为版本 ${saveFetcher.data.versionNo}`
|
||||
: saveFetcher.data.message || '规则草稿已保存');
|
||||
} else {
|
||||
setSaveMessage(saveFetcher.data.message || (saveFetcher.data.intent === 'publish' ? '规则版本已发布' : '规则版本已回滚'));
|
||||
}
|
||||
revalidator.revalidate();
|
||||
return;
|
||||
}
|
||||
setDraftSaved(false);
|
||||
setSaveMessage('');
|
||||
setSaveError(saveFetcher.data.message || '规则草稿保存失败');
|
||||
}, [revalidator, saveFetcher.data]);
|
||||
|
||||
const openRuleEditor = (rule?: RuleSummary) => {
|
||||
setRuleDraft(rule ? { ...rule } : emptyRuleDraft(ruleGroups[0]));
|
||||
@@ -493,10 +631,56 @@ export default function RulesTestDetail() {
|
||||
? current.map(rule => rule.id === editor.id ? normalizedRule : rule)
|
||||
: [...current, normalizedRule]);
|
||||
setSelectedRuleKey(ruleKey(normalizedRule));
|
||||
setDraftSaved(true);
|
||||
setDraftSaved(false);
|
||||
setSaveMessage('');
|
||||
setSaveError('');
|
||||
setEditor(null);
|
||||
};
|
||||
|
||||
const saveDraftToServer = () => {
|
||||
if (hasConfigErrors) {
|
||||
setShowValidation(true);
|
||||
setSaveError('当前规则配置仍有必改问题,请先处理后再保存。');
|
||||
setSaveMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('ruleType', pack.metadata.typeId || '');
|
||||
formData.append('yamlText', fullYamlText);
|
||||
formData.append('changeNote', `保存 ${pack.documentType}/${pack.mainType}/${pack.subtype} 规则配置`);
|
||||
formData.append('intent', 'save');
|
||||
saveFetcher.submit(formData, { method: 'post' });
|
||||
};
|
||||
|
||||
const publishDraftVersion = () => {
|
||||
if (!latestDraftVersion) {
|
||||
setSaveError('当前没有可发布的新版本,请先保存规则配置。');
|
||||
setSaveMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('intent', 'publish');
|
||||
formData.append('ruleType', pack.metadata.typeId || '');
|
||||
formData.append('versionId', String(latestDraftVersion.id));
|
||||
saveFetcher.submit(formData, { method: 'post' });
|
||||
};
|
||||
|
||||
const rollbackRuleVersion = () => {
|
||||
if (!rollbackTargetVersion) {
|
||||
setSaveError('当前没有可回滚的历史可用版本。');
|
||||
setSaveMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('intent', 'rollback');
|
||||
formData.append('ruleType', pack.metadata.typeId || '');
|
||||
formData.append('versionId', String(rollbackTargetVersion.id));
|
||||
saveFetcher.submit(formData, { method: 'post' });
|
||||
};
|
||||
|
||||
const resetDraft = () => {
|
||||
setRules(pack.rules);
|
||||
setSelectedRuleKey(requestedRuleId || ruleKey(pack.rules[0] || { id: '', ruleId: '' }));
|
||||
@@ -506,6 +690,8 @@ export default function RulesTestDetail() {
|
||||
setShowValidation(false);
|
||||
setShowYamlPreview(false);
|
||||
setDraftSaved(false);
|
||||
setSaveMessage('');
|
||||
setSaveError('');
|
||||
};
|
||||
|
||||
const dependencyColumns = [
|
||||
@@ -545,6 +731,9 @@ export default function RulesTestDetail() {
|
||||
<div className="config-toolbar-desc">
|
||||
{pack.documentType} / {pack.mainType} / {pack.subtype} / {currentRule?.ruleId || '-'}
|
||||
</div>
|
||||
<div className="config-toolbar-desc">
|
||||
当前版本:{currentResolvedVersion?.versionNo || '-'} / 当前状态:{versionStatusLabel(currentResolvedVersion?.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="config-toolbar-actions">
|
||||
<Link to={backLink} className="ant-btn ant-btn-default">
|
||||
@@ -559,6 +748,16 @@ export default function RulesTestDetail() {
|
||||
<button type="button" className="ant-btn ant-btn-default" onClick={resetDraft}>
|
||||
<i className="ri-refresh-line mr-1.5"></i>重置修改
|
||||
</button>
|
||||
<button type="button" className="ant-btn ant-btn-primary" disabled={saveButtonBusy} onClick={saveDraftToServer}>
|
||||
<i className={`${saveButtonBusy ? 'ri-loader-4-line' : 'ri-save-line'} mr-1.5`}></i>
|
||||
{saveButtonBusy ? '保存中...' : '保存规则配置'}
|
||||
</button>
|
||||
<button type="button" className="ant-btn ant-btn-default" disabled={saveButtonBusy || !latestDraftVersion} onClick={publishDraftVersion}>
|
||||
<i className="ri-upload-cloud-line mr-1.5"></i>发布版本
|
||||
</button>
|
||||
<button type="button" className="ant-btn ant-btn-default" disabled={saveButtonBusy || !rollbackTargetVersion} onClick={rollbackRuleVersion}>
|
||||
<i className="ri-history-line mr-1.5"></i>回滚版本
|
||||
</button>
|
||||
<button type="button" className="ant-btn ant-btn-primary" disabled={!currentRule} onClick={() => currentRule && openRuleEditor(currentRule)}>
|
||||
<i className="ri-edit-line mr-1.5"></i>编辑评查点
|
||||
</button>
|
||||
@@ -570,6 +769,18 @@ export default function RulesTestDetail() {
|
||||
草稿已保存。
|
||||
</div>
|
||||
)}
|
||||
{saveMessage && (
|
||||
<div className="draft-tip">
|
||||
<i className="ri-checkbox-circle-line"></i>
|
||||
{saveMessage}
|
||||
</div>
|
||||
)}
|
||||
{saveError && (
|
||||
<div className="draft-tip danger">
|
||||
<i className="ri-error-warning-line"></i>
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{showValidation && (
|
||||
|
||||
Reference in New Issue
Block a user