feat: add visual rule editor (#1251)

* feat: add visual rule editor

* Support MATCH

* Support Additional Parameters

* Use Trash Icon

* Support sorting

* Support adding prepend/append rules

* IoIosArrow -> IoMdArrow

* More checks

* Support delete with undo
This commit is contained in:
Memory 2025-10-11 07:02:14 +08:00 committed by GitHub
parent 6429e93adf
commit 2bf54446df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 690 additions and 0 deletions

View File

@ -0,0 +1,596 @@
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
Chip,
Input,
Select,
SelectItem,
Autocomplete,
AutocompleteItem,
Checkbox,
Divider
} from '@heroui/react'
import React, { useEffect, useState } from 'react'
import { getProfileStr, setProfileStr } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next'
import yaml from 'js-yaml'
import { IoMdTrash, IoMdArrowUp, IoMdArrowDown, IoMdUndo } from 'react-icons/io'
import { MdVerticalAlignTop, MdVerticalAlignBottom } from 'react-icons/md'
interface Props {
id: string
onClose: () => void
}
interface RuleItem {
type: string
payload: string
proxy: string
additionalParams?: string[]
}
// 内置路由规则 https://wiki.metacubex.one/config/rules/
const ruleTypes = [
'DOMAIN',
'DOMAIN-SUFFIX',
'DOMAIN-KEYWORD',
'DOMAIN-WILDCARD',
'DOMAIN-REGEX',
'GEOSITE',
'IP-CIDR',
'IP-CIDR6',
'IP-SUFFIX',
'IP-ASN',
'GEOIP',
'SRC-GEOIP',
'SRC-IP-ASN',
'SRC-IP-CIDR',
'SRC-IP-SUFFIX',
'DST-PORT',
'SRC-PORT',
'IN-PORT',
'IN-TYPE',
'IN-USER',
'IN-NAME',
'PROCESS-PATH',
'PROCESS-PATH-REGEX',
'PROCESS-NAME',
'PROCESS-NAME-REGEX',
'UID',
'NETWORK',
'DSCP',
'RULE-SET',
'AND',
'OR',
'NOT',
'SUB-RULE',
'MATCH'
]
// 支持 no-resolve 参数的规则类型
const noResolveSupportTypes = [
'IP-CIDR',
'IP-CIDR6',
'IP-SUFFIX',
'IP-ASN',
'GEOIP'
]
// 支持 src 参数的规则类型
const srcSupportTypes = [
'IP-CIDR',
'IP-CIDR6',
'IP-SUFFIX',
'IP-ASN',
'GEOIP'
]
const EditRulesModal: React.FC<Props> = (props) => {
const { id, onClose } = props
const [rules, setRules] = useState<RuleItem[]>([])
const [filteredRules, setFilteredRules] = useState<RuleItem[]>([])
const [profileContent, setProfileContent] = useState('')
const [newRule, setNewRule] = useState<RuleItem>({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] })
const [searchTerm, setSearchTerm] = useState('')
const [proxyGroups, setProxyGroups] = useState<string[]>([])
const [deletedRules, setDeletedRules] = useState<Set<number>>(new Set()) // Store indices of deleted rules
const { t } = useTranslation()
const getContent = async (): Promise<void> => {
const content = await getProfileStr(id)
setProfileContent(content)
try {
const parsed = yaml.load(content) as any
if (parsed && parsed.rules && Array.isArray(parsed.rules)) {
const parsedRules = parsed.rules.map((rule: string) => {
const parts = rule.split(',')
if (parts[0] === 'MATCH') {
return {
type: 'MATCH',
proxy: parts[1]
}
} else {
const additionalParams = parts.slice(3).filter(param => param.trim() !== '') || []
return {
type: parts[0],
payload: parts[1],
proxy: parts[2],
additionalParams
}
}
})
setRules(parsedRules)
setFilteredRules(parsedRules)
}
// 从配置文件中提取代理组
if (parsed) {
const groups: string[] = []
// 添加代理组名称
if (Array.isArray(parsed['proxy-groups'])) {
groups.push(...parsed['proxy-groups'].map((group: any) => group?.name).filter(Boolean))
}
// 添加代理名称
if (Array.isArray(parsed['proxies'])) {
groups.push(...parsed['proxies'].map((proxy: any) => proxy?.name).filter(Boolean))
}
// 预置出站 https://wiki.metacubex.one/config/proxies/built-in/
groups.push('DIRECT', 'REJECT', 'REJECT-DROP', 'PASS', 'COMPATIBLE')
// 去重
setProxyGroups([...new Set(groups)])
}
} catch (e) {
console.error('解析配置文件内容失败', e)
}
}
useEffect(() => {
getContent()
}, [])
useEffect(() => {
if (searchTerm === '') {
setFilteredRules(rules)
} else {
const filtered = rules.filter(rule =>
rule.type.toLowerCase().includes(searchTerm.toLowerCase()) ||
rule.payload.toLowerCase().includes(searchTerm.toLowerCase()) ||
(rule.proxy && rule.proxy.toLowerCase().includes(searchTerm.toLowerCase())) ||
(rule.additionalParams && rule.additionalParams.some(param => param.toLowerCase().includes(searchTerm.toLowerCase())))
)
setFilteredRules(filtered)
}
}, [searchTerm, rules])
const handleSave = async (): Promise<void> => {
try {
// 过滤掉已标记为删除的规则
const rulesToSave = rules.filter((_, index) => !deletedRules.has(index));
// 将规则转换回字符串格式
const ruleStrings = rulesToSave.map(rule => {
const parts = [rule.type]
if (rule.payload) parts.push(rule.payload)
if (rule.proxy) parts.push(rule.proxy)
if (rule.additionalParams && rule.additionalParams.length > 0) {
parts.push(...rule.additionalParams)
}
return parts.join(',')
})
// 直接在原始内容中替换规则部分,保持原有格式
let updatedContent = profileContent
// 将内容按行分割,便于处理
const lines = profileContent.split('\n')
const newLines: string[] = []
let inRulesSection = false
let rulesSectionFound = false
let rulesIndent = ''
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const trimmedLine = line.trim()
// 检查是否是 rules: 开始行
if (trimmedLine === 'rules:') {
rulesSectionFound = true
inRulesSection = true
rulesIndent = line.match(/^\s*/)?.[0] || ''
newLines.push(line) // 保留 rules: 行
// 添加新的规则行
for (const rule of ruleStrings) {
newLines.push(`${rulesIndent} - ${rule}`)
}
// 跳过原有的 rules 内容
while (i + 1 < lines.length) {
const nextLine = lines[i + 1]
const nextTrimmed = nextLine.trim()
const nextIndent = nextLine.match(/^\s*/)?.[0] || ''
// 如果下一行不是空行且缩进大于 rules 缩进,说明还是 rules 的内容
if (nextTrimmed !== '' && nextIndent.length > rulesIndent.length) {
i++ // 跳过这一行
} else {
break
}
}
continue
}
// 如果在 rules 部分中,跳过处理
if (inRulesSection && trimmedLine.startsWith('-')) {
// 检查是否还有 rules 行
const currentIndent = line.match(/^\s*/)?.[0] || ''
if (currentIndent.length > rulesIndent.length) {
continue // 跳过原有规则行
} else {
inRulesSection = false // rules 部分结束
}
}
newLines.push(line)
}
// 如果没有找到 rules 部分,添加到文件末尾
if (!rulesSectionFound) {
newLines.push('') // 空行
newLines.push('rules:')
for (const rule of ruleStrings) {
newLines.push(` - ${rule}`)
}
}
updatedContent = newLines.join('\n')
await setProfileStr(id, updatedContent)
onClose()
} catch (e) {
alert(t('profiles.editRules.saveError') + ': ' + (e instanceof Error ? e.message : String(e)))
}
}
const handleRuleTypeChange = (selected: string): void => {
const noResolveSupported = noResolveSupportTypes.includes(selected);
const srcSupported = srcSupportTypes.includes(selected);
let additionalParams = [...(newRule.additionalParams || [])];
if (!noResolveSupported) {
additionalParams = additionalParams.filter(param => param !== 'no-resolve');
}
if (!srcSupported) {
additionalParams = additionalParams.filter(param => param !== 'src');
}
setNewRule({
...newRule,
type: selected,
additionalParams: additionalParams.length > 0 ? additionalParams : []
});
};
const handleAdditionalParamChange = (param: string, checked: boolean): void => {
let newAdditionalParams = [...(newRule.additionalParams || [])];
if (checked) {
if (!newAdditionalParams.includes(param)) {
newAdditionalParams.push(param);
}
} else {
newAdditionalParams = newAdditionalParams.filter(p => p !== param);
}
setNewRule({
...newRule,
additionalParams: newAdditionalParams
});
};
const handleAddRule = (position: 'prepend' | 'append' = 'append'): void => {
if (newRule.payload.trim() !== '' || newRule.type === 'MATCH') {
const newRuleItem = { ...newRule };
let updatedRules: RuleItem[];
if (position === 'prepend') {
updatedRules = [newRuleItem, ...rules];
} else {
updatedRules = [...rules, newRuleItem];
}
setRules(updatedRules)
setFilteredRules(updatedRules)
setNewRule({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] })
}
}
const handleRemoveRule = (index: number): void => {
setDeletedRules(prev => {
const newSet = new Set(prev)
if (newSet.has(index)) {
newSet.delete(index) // 如果已经标记为删除,则取消标记
} else {
newSet.add(index) // 标记为删除
}
return newSet
})
}
const handleMoveRuleUp = (index: number): void => {
if (index <= 0) return
const updatedRules = [...rules]
const temp = updatedRules[index]
updatedRules[index] = updatedRules[index - 1]
updatedRules[index - 1] = temp
setRules(updatedRules)
setFilteredRules(updatedRules)
setDeletedRules(prev => {
const newSet = new Set<number>()
prev.forEach(idx => {
if (idx === index) {
newSet.add(index - 1)
} else if (idx === index - 1) {
newSet.add(index)
} else {
newSet.add(idx)
}
})
return newSet
})
}
const handleMoveRuleDown = (index: number): void => {
if (index >= rules.length - 1) return
const updatedRules = [...rules]
const temp = updatedRules[index]
updatedRules[index] = updatedRules[index + 1]
updatedRules[index + 1] = temp
setRules(updatedRules)
setFilteredRules(updatedRules)
setDeletedRules(prev => {
const newSet = new Set<number>()
prev.forEach(idx => {
if (idx === index) {
newSet.add(index + 1)
} else if (idx === index + 1) {
newSet.add(index)
} else {
newSet.add(idx)
}
})
return newSet
})
}
return (
<Modal
backdrop="blur"
classNames={{ backdrop: 'top-[48px]' }}
size="5xl"
hideCloseButton
isOpen={true}
onOpenChange={onClose}
scrollBehavior="inside"
>
<ModalContent className="h-full w-[calc(100%-100px)]">
<ModalHeader className="flex pb-0 app-drag">
<div className="flex justify-start">
<div className="flex items-center">{t('profiles.editRules.title')}</div>
</div>
</ModalHeader>
<ModalBody className="h-full">
<div className="flex gap-4 h-full">
{/* 左侧面板 - 规则表单 */}
<div className="w-1/3 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Select
label={t('profiles.editRules.ruleType')}
selectedKeys={[newRule.type]}
onSelectionChange={(keys) => {
const selected = Array.from(keys)[0] as string
handleRuleTypeChange(selected)
}}
>
{ruleTypes.map((type) => (
<SelectItem key={type}>
{type}
</SelectItem>
))}
</Select>
<Input
label={t('profiles.editRules.payload')}
placeholder={t('profiles.editRules.payloadPlaceholder')}
value={newRule.payload}
onValueChange={(value) => setNewRule({ ...newRule, payload: value })}
isDisabled={newRule.type === 'MATCH'}
/>
<Autocomplete
label={t('profiles.editRules.proxy')}
placeholder={t('profiles.editRules.proxyPlaceholder')}
selectedKey={newRule.proxy}
onSelectionChange={(key) => setNewRule({ ...newRule, proxy: key as string })}
inputValue={newRule.proxy}
onInputChange={(value) => setNewRule({ ...newRule, proxy: value })}
>
{proxyGroups.map((group) => (
<AutocompleteItem key={group} textValue={group}>
{group}
</AutocompleteItem>
))}
</Autocomplete>
{/* 附加参数 */}
{(noResolveSupportTypes.includes(newRule.type) || srcSupportTypes.includes(newRule.type)) && (
<>
<Divider className="my-2" />
<div className="flex flex-col gap-2">
{noResolveSupportTypes.includes(newRule.type) && (
<Checkbox
isSelected={newRule.additionalParams?.includes('no-resolve') || false}
onValueChange={(checked) => handleAdditionalParamChange('no-resolve', checked)}
>
{t('profiles.editRules.noResolve')}
</Checkbox>
)}
{srcSupportTypes.includes(newRule.type) && (
<Checkbox
isSelected={newRule.additionalParams?.includes('src') || false}
onValueChange={(checked) => handleAdditionalParamChange('src', checked)}
>
{t('profiles.editRules.src')}
</Checkbox>
)}
</div>
</>
)}
<div className="flex flex-col gap-2">
<Button
color="primary"
onPress={() => handleAddRule('prepend')}
isDisabled={!(newRule.payload.trim() || newRule.type === 'MATCH') || !newRule.type}
startContent={<MdVerticalAlignTop className="text-lg" />}
>
{t('profiles.editRules.addRulePrepend')}
</Button>
<Button
color="primary"
variant="bordered"
onPress={() => handleAddRule('append')}
isDisabled={!(newRule.payload.trim() || newRule.type === 'MATCH') || !newRule.type}
startContent={<MdVerticalAlignBottom className="text-lg" />}
>
{t('profiles.editRules.addRuleAppend')}
</Button>
</div>
</div>
<div className="flex-1 border-t border-divider pt-4">
<h3 className="text-lg font-semibold mb-2">{t('profiles.editRules.instructions')}</h3>
<div className="text-sm text-foreground-500">
<p className="mb-2">{t('profiles.editRules.instructions1')}</p>
<p className="mb-2">{t('profiles.editRules.instructions2')}</p>
<p>{t('profiles.editRules.instructions3')}</p>
</div>
</div>
</div>
{/* 右侧面板 - 规则列表 */}
<div className="w-2/3 border-l border-divider pl-4 flex flex-col">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-semibold">{t('profiles.editRules.currentRules')}</h3>
<Input
size="sm"
placeholder={t('profiles.editRules.searchPlaceholder')}
className="w-1/3"
value={searchTerm}
onValueChange={setSearchTerm}
/>
</div>
<div className="flex flex-col gap-2 max-h-[calc(100vh-200px)] overflow-y-auto flex-1">
{filteredRules.length === 0 ? (
<div className="text-center text-foreground-500 py-4">
{rules.length === 0
? t('profiles.editRules.noRules')
: searchTerm
? t('profiles.editRules.noMatchingRules')
: t('profiles.editRules.noRules')}
</div>
) : (
filteredRules.map((rule) => {
const originalIndex = rules.indexOf(rule);
return (
<div key={originalIndex} className={`flex items-center gap-2 p-2 rounded-lg ${deletedRules.has(originalIndex) ? 'bg-danger-50 opacity-70' : 'bg-content2'}`}>
<div className="flex flex-col">
<div className="flex items-center gap-1">
<Chip size="sm" variant="flat">
{rule.type}
</Chip>
{/* 显示附加参数 */}
<div className="flex gap-1">
{rule.additionalParams && rule.additionalParams.length > 0 && (
rule.additionalParams.map((param, idx) => (
<Chip key={idx} size="sm" variant="flat" color="secondary">{param}</Chip>
))
)}
</div>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{rule.type === 'MATCH' ? rule.proxy : rule.payload}
</div>
{rule.proxy && rule.type !== 'MATCH' && (
<div className="text-sm text-foreground-500 truncate">{rule.proxy}</div>
)}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="light"
onPress={() => handleMoveRuleUp(originalIndex)}
isIconOnly
isDisabled={originalIndex === 0 || deletedRules.has(originalIndex)}
>
<IoMdArrowUp className="text-lg" />
</Button>
<Button
size="sm"
variant="light"
onPress={() => handleMoveRuleDown(originalIndex)}
isIconOnly
isDisabled={originalIndex === rules.length - 1 || deletedRules.has(originalIndex)}
>
<IoMdArrowDown className="text-lg" />
</Button>
<Button
size="sm"
color={deletedRules.has(originalIndex) ? "success" : "danger"}
variant="light"
onPress={() => handleRemoveRule(originalIndex)}
isIconOnly
>
{deletedRules.has(originalIndex) ? <IoMdUndo className="text-lg" /> : <IoMdTrash className="text-lg" />}
</Button>
</div>
</div>
)
})
)}
</div>
</div>
</div>
</ModalBody>
<ModalFooter className="pt-0">
<Button size="sm" variant="light" onPress={() => {
setDeletedRules(new Set()) // 清除删除状态
onClose()
}}>
{t('common.cancel')}
</Button>
<Button
size="sm"
color="primary"
onPress={handleSave}
>
{t('common.save')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default EditRulesModal

View File

@ -17,6 +17,7 @@ import dayjs from '@renderer/utils/dayjs'
import React, { Key, useMemo, useState } from 'react' import React, { Key, useMemo, useState } from 'react'
import EditFileModal from './edit-file-modal' import EditFileModal from './edit-file-modal'
import EditInfoModal from './edit-info-modal' import EditInfoModal from './edit-info-modal'
import EditRulesModal from './edit-rules-modal'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { openFile } from '@renderer/utils/ipc' import { openFile } from '@renderer/utils/ipc'
@ -59,6 +60,7 @@ const ProfileItem: React.FC<Props> = (props) => {
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const [openInfoEditor, setOpenInfoEditor] = useState(false) const [openInfoEditor, setOpenInfoEditor] = useState(false)
const [openFileEditor, setOpenFileEditor] = useState(false) const [openFileEditor, setOpenFileEditor] = useState(false)
const [openRulesEditor, setOpenRulesEditor] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false)
const { const {
attributes, attributes,
@ -90,6 +92,13 @@ const ProfileItem: React.FC<Props> = (props) => {
color: 'default', color: 'default',
className: '' className: ''
} as MenuItem, } as MenuItem,
{
key: 'edit-rules',
label: t('profiles.editRules.title'),
showDivider: false,
color: 'default',
className: ''
} as MenuItem,
{ {
key: 'open-file', key: 'open-file',
label: t('profiles.openFile'), label: t('profiles.openFile'),
@ -127,6 +136,10 @@ const ProfileItem: React.FC<Props> = (props) => {
setOpenFileEditor(true) setOpenFileEditor(true)
break break
} }
case 'edit-rules': {
setOpenRulesEditor(true)
break
}
case 'open-file': { case 'open-file': {
openFile('profile', info.id) openFile('profile', info.id)
break break
@ -201,6 +214,7 @@ const ProfileItem: React.FC<Props> = (props) => {
}} }}
> >
{openFileEditor && <EditFileModal id={info.id} onClose={() => setOpenFileEditor(false)} />} {openFileEditor && <EditFileModal id={info.id} onClose={() => setOpenFileEditor(false)} />}
{openRulesEditor && <EditRulesModal id={info.id} onClose={() => setOpenRulesEditor(false)} />}
{openInfoEditor && ( {openInfoEditor && (
<EditInfoModal <EditInfoModal
item={info} item={info}

View File

@ -434,6 +434,26 @@
"profiles.editFile.notice": "Note: Changes made here will be reset after profile update. For custom configurations, please use", "profiles.editFile.notice": "Note: Changes made here will be reset after profile update. For custom configurations, please use",
"profiles.editFile.override": "Override", "profiles.editFile.override": "Override",
"profiles.editFile.feature": "feature", "profiles.editFile.feature": "feature",
"profiles.editRules.title": "Edit Rules",
"profiles.editRules.ruleType": "Rule Type",
"profiles.editRules.payload": "Payload",
"profiles.editRules.payloadPlaceholder": "Enter payload",
"profiles.editRules.proxy": "Proxy",
"profiles.editRules.proxyPlaceholder": "Select or enter proxy",
"profiles.editRules.addRule": "Add Rule",
"profiles.editRules.addRulePrepend": "Add Rule to Top",
"profiles.editRules.addRuleAppend": "Add Rule to Bottom",
"profiles.editRules.instructions": "Instructions",
"profiles.editRules.instructions1": "1. Select rule type from dropdown menu",
"profiles.editRules.instructions2": "2. Enter payload",
"profiles.editRules.instructions3": "3. Select proxy and click Add Rule",
"profiles.editRules.currentRules": "Current Rules",
"profiles.editRules.searchPlaceholder": "Search rules...",
"profiles.editRules.noRules": "No rules",
"profiles.editRules.noMatchingRules": "No matching rules",
"profiles.editRules.saveError": "Error saving rules",
"profiles.editRules.noResolve": "Skip DNS Resolution (no-resolve)",
"profiles.editRules.src": "Match Source IP (src)",
"profiles.openFile": "Open File", "profiles.openFile": "Open File",
"profiles.home": "Home", "profiles.home": "Home",
"profiles.traffic.usage": "{{used}}/{{total}}", "profiles.traffic.usage": "{{used}}/{{total}}",

View File

@ -414,6 +414,26 @@
"profiles.editFile.notice": "توجه: تغییرات اعمال شده در اینجا پس از به‌روزرسانی پروفایل بازنشانی می‌شوند. برای پیکربندی‌های سفارشی، لطفا از", "profiles.editFile.notice": "توجه: تغییرات اعمال شده در اینجا پس از به‌روزرسانی پروفایل بازنشانی می‌شوند. برای پیکربندی‌های سفارشی، لطفا از",
"profiles.editFile.override": "جایگزینی", "profiles.editFile.override": "جایگزینی",
"profiles.editFile.feature": "ویژگی", "profiles.editFile.feature": "ویژگی",
"profiles.editRules.title": "ویرایش قوانین",
"profiles.editRules.ruleType": "نوع قانون",
"profiles.editRules.payload": "محتوا",
"profiles.editRules.payloadPlaceholder": "محتوا را وارد کنید",
"profiles.editRules.proxy": "پروکسی",
"profiles.editRules.proxyPlaceholder": "پروکسی را انتخاب یا وارد کنید",
"profiles.editRules.addRule": "افزودن قانون",
"profiles.editRules.addRulePrepend": "افزودن قانون به ابتدا",
"profiles.editRules.addRuleAppend": "افزودن قانون به انتها",
"profiles.editRules.instructions": "دستورالعمل‌ها",
"profiles.editRules.instructions1": "۱. نوع قانون را از منوی کشویی انتخاب کنید",
"profiles.editRules.instructions2": "۲. محتوا را وارد کنید",
"profiles.editRules.instructions3": "۳. پروکسی را انتخاب کرده و روی افزودن قانون کلیک کنید",
"profiles.editRules.currentRules": "قوانین فعلی",
"profiles.editRules.searchPlaceholder": "جستجوی قوانین...",
"profiles.editRules.noRules": "بدون قانون",
"profiles.editRules.noMatchingRules": "قانونی یافت نشد",
"profiles.editRules.saveError": "خطا در ذخیره قوانین",
"profiles.editRules.noResolve": "رد کردن تحلیل DNS (no-resolve)",
"profiles.editRules.src": "مطابقت با IP منبع (src)",
"profiles.openFile": "باز کردن فایل", "profiles.openFile": "باز کردن فایل",
"profiles.home": "خانه", "profiles.home": "خانه",
"profiles.notification.importSuccess": "اشتراک با موفقیت وارد شد", "profiles.notification.importSuccess": "اشتراک با موفقیت وارد شد",

View File

@ -414,6 +414,26 @@
"profiles.editFile.notice": "Примечание: Изменения, сделанные здесь, будут сброшены после обновления профиля. Для пользовательских настроек используйте", "profiles.editFile.notice": "Примечание: Изменения, сделанные здесь, будут сброшены после обновления профиля. Для пользовательских настроек используйте",
"profiles.editFile.override": "Переопределение", "profiles.editFile.override": "Переопределение",
"profiles.editFile.feature": "функцию", "profiles.editFile.feature": "функцию",
"profiles.editRules.title": "Редактировать правила",
"profiles.editRules.ruleType": "Тип правила",
"profiles.editRules.payload": "Полезная нагрузка",
"profiles.editRules.payloadPlaceholder": "Введите полезную нагрузку",
"profiles.editRules.proxy": "Прокси",
"profiles.editRules.proxyPlaceholder": "Выберите или введите прокси",
"profiles.editRules.addRule": "Добавить правило",
"profiles.editRules.addRulePrepend": "Добавить правило в начало",
"profiles.editRules.addRuleAppend": "Добавить правило в конец",
"profiles.editRules.instructions": "Инструкции",
"profiles.editRules.instructions1": "1. Выберите тип правила из выпадающего меню",
"profiles.editRules.instructions2": "2. Введите полезную нагрузку",
"profiles.editRules.instructions3": "3. Выберите прокси и нажмите Добавить правило",
"profiles.editRules.currentRules": "Текущие правила",
"profiles.editRules.searchPlaceholder": "Поиск правил...",
"profiles.editRules.noRules": "Нет правил",
"profiles.editRules.noMatchingRules": "Нет совпадающих правил",
"profiles.editRules.saveError": "Ошибка сохранения правил",
"profiles.editRules.noResolve": "Пропустить DNS-резолвинг (no-resolve)",
"profiles.editRules.src": "Сопоставить исходный IP (src)",
"profiles.openFile": "Открыть файл", "profiles.openFile": "Открыть файл",
"profiles.home": "Главная", "profiles.home": "Главная",
"profiles.notification.importSuccess": "Подписка успешно импортирована", "profiles.notification.importSuccess": "Подписка успешно импортирована",

View File

@ -439,6 +439,26 @@
"profiles.editFile.notice": "注意:此处编辑配置更新订阅后会还原,如需要自定义配置请使用", "profiles.editFile.notice": "注意:此处编辑配置更新订阅后会还原,如需要自定义配置请使用",
"profiles.editFile.override": "覆写", "profiles.editFile.override": "覆写",
"profiles.editFile.feature": "功能", "profiles.editFile.feature": "功能",
"profiles.editRules.title": "编辑规则",
"profiles.editRules.ruleType": "规则类型",
"profiles.editRules.payload": "匹配内容",
"profiles.editRules.payloadPlaceholder": "请输入匹配内容",
"profiles.editRules.proxy": "代理节点",
"profiles.editRules.proxyPlaceholder": "请选择或输入代理节点",
"profiles.editRules.addRule": "添加规则",
"profiles.editRules.addRulePrepend": "添加前置规则",
"profiles.editRules.addRuleAppend": "添加后置规则",
"profiles.editRules.instructions": "使用说明",
"profiles.editRules.instructions1": "1. 在下拉菜单中选择规则类型",
"profiles.editRules.instructions2": "2. 输入匹配内容",
"profiles.editRules.instructions3": "3. 选择指定代理,然后点击添加规则",
"profiles.editRules.currentRules": "当前规则",
"profiles.editRules.searchPlaceholder": "搜索规则...",
"profiles.editRules.noRules": "暂无规则",
"profiles.editRules.noMatchingRules": "没有匹配的规则",
"profiles.editRules.saveError": "保存规则时出错",
"profiles.editRules.noResolve": "跳过DNS解析 (no-resolve)",
"profiles.editRules.src": "匹配源IP (src)",
"profiles.openFile": "打开文件", "profiles.openFile": "打开文件",
"profiles.home": "主页", "profiles.home": "主页",
"profiles.notification.importSuccess": "订阅导入成功", "profiles.notification.importSuccess": "订阅导入成功",