feat: align frontend document and rule management flows

This commit is contained in:
wren
2026-05-06 09:40:37 +08:00
parent 8a5044b024
commit c54f84382b
41 changed files with 4239 additions and 2903 deletions
+220 -9
View File
@@ -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 && (