完善规则配置页交互细节

This commit is contained in:
2026-04-29 18:09:56 +08:00
committed by wren
parent 2eb40e8af6
commit 9e0909ab35
5 changed files with 195 additions and 55 deletions
+6 -3
View File
@@ -13,12 +13,14 @@ interface FilterSelectProps {
options: FilterOption[];
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
className?: string;
disabled?: boolean;
placeholder?: string;
}
/**
* 筛选下拉选择框组件
*/
const FilterSelect = ({ label, name, value, options, onChange, className = '' }: FilterSelectProps) => (
const FilterSelect = ({ label, name, value, options, onChange, className = '', disabled = false, placeholder = '全部' }: FilterSelectProps) => (
<div className={`filter-item ${className}`}>
<label className="filter-label">{label}</label>
<select
@@ -26,8 +28,9 @@ const FilterSelect = ({ label, name, value, options, onChange, className = '' }:
name={name}
value={value}
onChange={onChange}
disabled={disabled}
>
<option value=""></option>
<option value="">{placeholder}</option>
{options.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
@@ -201,4 +204,4 @@ export function DateRangeFilter({
}
// 导出筛选下拉框组件和日期范围筛选组件
export { FilterSelect };
export { FilterSelect };
+64 -26
View File
@@ -1,7 +1,7 @@
import { type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node';
import { Link, useLoaderData } from '@remix-run/react';
import type React from 'react';
import { useEffect, useMemo, useState } 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';
@@ -328,6 +328,7 @@ export default function RulesTestDetail() {
const [showValidation, setShowValidation] = useState(false);
const [showYamlPreview, setShowYamlPreview] = useState(false);
const [draftSaved, setDraftSaved] = useState(false);
const promptEditorRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setRules(pack.rules);
@@ -448,6 +449,33 @@ export default function RulesTestDetail() {
setDependencyDialogOpen(false);
};
const insertDependencyVariable = (value: string) => {
const variable = `{{${value}}}`;
const textarea = promptEditorRef.current;
const prompt = ruleDraft.prompt || '';
const start = textarea?.selectionStart ?? prompt.length;
const end = textarea?.selectionEnd ?? prompt.length;
const prefix = prompt.slice(0, start);
const suffix = prompt.slice(end);
const nextPrompt = `${prefix}${variable}${suffix}`;
setRuleDraft({ ...ruleDraft, prompt: nextPrompt });
window.setTimeout(() => {
if (!promptEditorRef.current) return;
const nextCursor = start + variable.length;
promptEditorRef.current.focus();
promptEditorRef.current.setSelectionRange(nextCursor, nextCursor);
}, 0);
};
const removeDependency = (value: string) => {
setRuleDraft({
...ruleDraft,
dependencies: ruleDraft.dependencies.filter(dependency => dependency !== value)
});
};
const saveRule = () => {
if (!editor || editor.kind !== 'rule') return;
const existingRule = editor.id ? rules.find(rule => rule.id === editor.id) : undefined;
@@ -488,7 +516,6 @@ export default function RulesTestDetail() {
render: (_: unknown, record: DependencyOption) => (
<div className="rule-name">
<strong>{record.label}</strong>
<span>YAML引用{record.value}</span>
</div>
)
},
@@ -540,7 +567,7 @@ export default function RulesTestDetail() {
{draftSaved && (
<div className="draft-tip">
<i className="ri-checkbox-circle-line"></i>
稿 OSS
稿
</div>
)}
</Card>
@@ -615,10 +642,16 @@ export default function RulesTestDetail() {
)}
</Card>
{(pack.fields.length > 0 || pack.subDocuments.length > 0 || pack.visualElements.length > 0) && (
<>
{pack.fields.length > 0 && <span id="fields" className="section-anchor" aria-hidden="true"></span>}
{pack.subDocuments.length > 0 && <span id="sub-documents" className="section-anchor" aria-hidden="true"></span>}
{pack.visualElements.length > 0 && <span id="visual-elements" className="section-anchor" aria-hidden="true"></span>}
</>
)}
<Card
className="ant-card"
title={`依赖字段 (${currentRule.dependencies.length}项)`}
extra={<Button size="small" type="primary" icon="ri-add-line" onClick={() => openRuleEditor(currentRule)}></Button>}
>
<Table
className="rules-test-table"
@@ -629,6 +662,7 @@ export default function RulesTestDetail() {
/>
</Card>
<span id="rules" className="section-anchor" aria-hidden="true"></span>
<Card className="ant-card" title="评查规则">
<div className="rule-content-stack">
{currentRule.subRules.length > 0 && (
@@ -636,13 +670,12 @@ export default function RulesTestDetail() {
<div className="drawer-subsection-header">
<div>
<strong>{currentRule.type === 'rule_group' ? `子规则与逻辑(${currentRule.subRules.length}步)` : `规则步骤(${currentRule.subRules.length}步)`}</strong>
<span>{currentRule.type === 'rule_group' ? '规则组合只维护子规则编号、内容和逻辑表达式,不在此处维护字段库。' : '展示当前评查点 YAML stages 中的每一个检查步骤。'}</span>
</div>
</div>
<div className="subrule-list">
{currentRule.subRules.map(subRule => (
<div key={subRule.id} className="subrule-item">
<Tag color="gray" size="sm">{subRule.id}</Tag>
<span className="subrule-index">{subRule.id}</span>
<div>
<strong>
{checkTypeLabel(subRule.check)}
@@ -671,7 +704,6 @@ export default function RulesTestDetail() {
<div className="drawer-subsection-header">
<div>
<strong></strong>
<span></span>
</div>
</div>
<div className="subrule-list">
@@ -679,10 +711,10 @@ export default function RulesTestDetail() {
const referencedRule = rulesById.get(ruleId);
return (
<div key={ruleId} className="subrule-item">
<Tag color="gray" size="sm">{ruleId}</Tag>
<span className="subrule-index">{ruleId}</span>
<div>
<strong>{referencedRule?.name || '引用规则'}</strong>
<span>{referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '当前 YAML 未找到对应规则内容'}</span>
<span>{referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '未找到对应规则内容'}</span>
</div>
</div>
);
@@ -702,7 +734,6 @@ export default function RulesTestDetail() {
<div className="drawer-subsection-header">
<div>
<strong></strong>
<span></span>
</div>
</div>
<pre className="rule-prompt-preview">{currentRule.prompt || '当前评查点尚未维护提示词。'}</pre>
@@ -732,7 +763,6 @@ export default function RulesTestDetail() {
<div className="rules-drawer-header">
<div>
<h3>{editor.mode === 'edit' ? '编辑评查点' : '新增评查点'}</h3>
<p></p>
</div>
<button type="button" className="drawer-close" onClick={() => setEditor(null)}><i className="ri-close-line"></i></button>
</div>
@@ -792,6 +822,7 @@ export default function RulesTestDetail() {
<label>
<span></span>
<textarea
ref={promptEditorRef}
className="prompt-editor"
value={ruleDraft.prompt}
onChange={event => setRuleDraft({ ...ruleDraft, prompt: event.target.value })}
@@ -804,13 +835,12 @@ export default function RulesTestDetail() {
<div className="drawer-subsection-header">
<div>
<strong></strong>
<span></span>
</div>
</div>
<div className="subrule-list">
{ruleDraft.subRules.length > 0 ? ruleDraft.subRules.map(subRule => (
<div key={subRule.id} className="subrule-item">
<Tag color="gray" size="sm">{subRule.id}</Tag>
<span className="subrule-index">{subRule.id}</span>
<div>
<strong>{checkTypeLabel(subRule.check)}</strong>
<span>{subRule.content}</span>
@@ -820,10 +850,10 @@ export default function RulesTestDetail() {
const referencedRule = rulesById.get(ruleId);
return (
<div key={ruleId} className="subrule-item">
<Tag color="gray" size="sm">{ruleId}</Tag>
<span className="subrule-index">{ruleId}</span>
<div>
<strong>{referencedRule?.name || '引用规则'}</strong>
<span>{referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '当前 YAML 未找到对应规则内容'}</span>
<span>{referencedRule ? `${ruleTypeLabel(referencedRule.type)} / ${referencedRule.group}` : '未找到对应规则内容'}</span>
</div>
</div>
);
@@ -847,18 +877,27 @@ export default function RulesTestDetail() {
{selectedDependencyOptions.length === 0 ? (
<div className="drawer-empty"></div>
) : selectedDependencyOptions.map(option => (
<Tag
<span
key={option.value}
color="green"
size="sm"
closable
onClose={() => setRuleDraft({
...ruleDraft,
dependencies: ruleDraft.dependencies.filter(dependency => dependency !== option.value)
})}
className="dependency-variable-button"
>
{option.label}
</Tag>
<button
type="button"
className="dependency-variable-main"
onClick={() => insertDependencyVariable(option.value)}
title={`插入变量 {{${option.value}}}`}
>
{option.label}
</button>
<button
type="button"
className="dependency-variable-remove"
onClick={() => removeDependency(option.value)}
aria-label={`移除依赖字段 ${option.label}`}
>
<i className="ri-close-line"></i>
</button>
</span>
))}
<Button size="small" type="default" icon="ri-add-line" onClick={openDependencyDialog}></Button>
</div>
@@ -880,7 +919,6 @@ export default function RulesTestDetail() {
<div className="dependency-dialog-header">
<div>
<h3></h3>
<p></p>
</div>
<button type="button" className="drawer-close" onClick={() => setDependencyDialogOpen(false)}><i className="ri-close-line"></i></button>
</div>
+27 -17
View File
@@ -104,12 +104,19 @@ export async function loader({ request }: LoaderFunctionArgs) {
? requestedFilters.subtype
: ''
};
const scopedPacks = packs.filter(pack =>
const scopedByMainTypePacks = packs.filter(pack =>
pack.documentType === scopedFilters.documentType &&
(!scopedFilters.mainType || pack.mainType === scopedFilters.mainType) &&
(!scopedFilters.subtype || pack.subtype === scopedFilters.subtype)
(!scopedFilters.mainType || pack.mainType === scopedFilters.mainType)
);
const ruleGroupOptions = unique(scopedPacks.flatMap(pack => pack.rules.map(rule => rule.group))).sort((a, b) => a.localeCompare(b, 'zh-CN'));
const subtypeOptions = unique(scopedByMainTypePacks.map(pack => pack.subtype));
const ruleGroupSourcePacks = scopedFilters.subtype
? scopedByMainTypePacks.filter(pack => pack.subtype === scopedFilters.subtype)
: subtypeOptions.length <= 1
? scopedByMainTypePacks
: [];
const ruleGroupOptions = unique(
ruleGroupSourcePacks.flatMap(pack => pack.rules.map(rule => rule.group))
).sort((a, b) => a.localeCompare(b, 'zh-CN'));
const filters = {
...scopedFilters,
ruleGroup: ruleGroupOptions.includes(requestedFilters.ruleGroup) ? requestedFilters.ruleGroup : ''
@@ -189,10 +196,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
options: {
documentTypes,
mainTypes: unique(packs.filter(pack => pack.documentType === filters.documentType).map(pack => pack.mainType)),
subtypes: unique(packs.filter(pack =>
pack.documentType === filters.documentType &&
(!filters.mainType || pack.mainType === filters.mainType)
).map(pack => pack.subtype)),
subtypes: subtypeOptions,
ruleGroups: ruleGroupOptions
}
} satisfies LoaderData);
@@ -222,6 +226,10 @@ export default function RulesTestList() {
const handleFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const { name, value } = event.target;
if (name === 'subtype') {
updateParams({ subtype: value, documentAttributeType: undefined, ruleGroup: undefined });
return;
}
updateParams({ [name]: value });
};
@@ -336,15 +344,6 @@ export default function RulesTestList() {
</Button>
}
>
<FilterSelect
label="所属规则组"
name="ruleGroup"
value={filters.ruleGroup}
options={options.ruleGroups.map(group => ({ value: group, label: group }))}
onChange={handleFilterChange}
className="mr-3 w-[22%]"
/>
<FilterSelect
label="子类型"
name="subtype"
@@ -354,6 +353,17 @@ export default function RulesTestList() {
className="mr-3 w-[18%]"
/>
<FilterSelect
label="所属规则组"
name="ruleGroup"
value={filters.ruleGroup}
options={options.ruleGroups.map(group => ({ value: group, label: group }))}
onChange={handleFilterChange}
className="mr-3 w-[22%]"
disabled={options.ruleGroups.length === 0}
placeholder={filters.subtype || options.subtypes.length <= 1 ? '全部' : '请先选择子类型'}
/>
<SearchFilter
key={filters.keyword}
label="搜索"
+88 -1
View File
@@ -9,6 +9,14 @@
scroll-margin-top: 100px;
}
.rules-test-page .tag.tag-sm {
min-height: 20px;
padding: 2px 8px;
align-items: center;
justify-content: center;
line-height: 1;
}
.rules-test-page #fields,
.rules-test-page #sub-documents,
.rules-test-page #visual-elements,
@@ -844,6 +852,63 @@
background: #fbfdfc;
}
.rules-test-page .dependency-variable-button {
display: inline-flex;
align-items: stretch;
min-height: 32px;
overflow: hidden;
border: 1px solid #00684a;
border-radius: 6px;
background: #00684a;
color: #fff;
font-size: 14px;
font-weight: 500;
line-height: 1.2;
transition: all 0.2s ease-in-out;
}
.rules-test-page .dependency-variable-button:hover {
border-color: #005a3f;
background: #005a3f;
color: #fff;
}
.rules-test-page .dependency-variable-main,
.rules-test-page .dependency-variable-remove {
display: inline-flex;
align-items: center;
border: 0;
background: transparent;
color: inherit;
line-height: 1;
}
.rules-test-page .dependency-variable-main {
max-width: 260px;
padding: 7px 10px 7px 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rules-test-page .dependency-variable-remove {
justify-content: center;
width: 30px;
border-left: 1px solid rgba(255, 255, 255, 0.28);
color: rgba(255, 255, 255, 0.85);
}
.rules-test-page .dependency-variable-remove:hover {
background: rgba(255, 255, 255, 0.16);
color: #fff;
}
.rules-test-page .dependency-variable-main:focus-visible,
.rules-test-page .dependency-variable-remove:focus-visible {
outline: 2px solid rgba(0, 104, 74, 0.18);
outline-offset: -2px;
}
.rules-test-page .subrule-list {
display: grid;
gap: 8px;
@@ -851,7 +916,7 @@
.rules-test-page .subrule-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-columns: 34px minmax(0, 1fr);
align-items: start;
gap: 10px;
padding: 9px 10px;
@@ -860,6 +925,22 @@
background: #fff;
}
.rules-test-page .subrule-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
min-width: 30px;
border-radius: 50%;
background: #00684a;
color: #fff;
font-size: 13px;
font-weight: 650;
line-height: 1;
box-shadow: 0 2px 6px rgba(0, 104, 74, 0.18);
}
.rules-test-page .subrule-item div {
display: flex;
flex-direction: column;
@@ -882,6 +963,12 @@
line-height: 1.45;
}
.rules-test-page .subrule-item > .subrule-index {
color: #fff;
font-size: 13px;
line-height: 1;
}
.rules-test-page .drawer-field-item {
display: flex;
align-items: center;
+10 -8
View File
@@ -54,12 +54,13 @@ export function getDefaultExpandedDependencyGroups(options: DependencyOption[],
export function collectDependencyOptions(config: Pick<EditableRuleConfig, 'fields' | 'subDocuments' | 'visualElements'>): DependencyOption[] {
const topLevelFields = config.fields.flatMap(field => {
const source = field.group ? `字段抽取 / ${field.group}` : '字段抽取';
const source = '字段抽取';
const group = field.group || '未分组';
const options = [{
value: field.name,
label: field.name,
source,
group: source
group
}];
if (field.group === '派生字段') {
options.push({
@@ -74,7 +75,7 @@ export function collectDependencyOptions(config: Pick<EditableRuleConfig, 'field
value: field.name.replace('[*].', '.'),
label: field.name.replace('[*].', ' / '),
source,
group: source
group
});
}
return options;
@@ -105,31 +106,32 @@ export function collectDependencyOptions(config: Pick<EditableRuleConfig, 'field
const visualElements = config.visualElements.flatMap(item => {
const label = item.name || item.id;
const source = `视觉要素 / ${item.type}`;
const source = '视觉要素';
const group = item.type || '未分组';
return [
{
value: item.id,
label,
source,
group: source
group
},
{
value: item.name || item.id,
label,
source,
group: source
group
},
{
value: `visual.${item.id}`,
label,
source,
group: source
group
},
{
value: `visual.${item.name || item.id}`,
label,
source,
group: source
group
},
{
value: item.type,