Compare commits

...

2 Commits

Author SHA1 Message Date
Memory
2bf54446df
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
2025-10-11 07:02:14 +08:00
Leon Wang
6429e93adf
fix: inconsistent applied profile when rapidly switching profiles (#1268) 2025-10-11 07:01:51 +08:00
7 changed files with 717 additions and 20 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

@ -1,12 +1,12 @@
import React, { createContext, useContext, ReactNode } from 'react' import React, { createContext, ReactNode, useContext } from 'react'
import useSWR from 'swr' import useSWR from 'swr'
import { import {
getProfileConfig,
setProfileConfig as set,
addProfileItem as add, addProfileItem as add,
changeCurrentProfile as change,
getProfileConfig,
removeProfileItem as remove, removeProfileItem as remove,
updateProfileItem as update, setProfileConfig as set,
changeCurrentProfile as change updateProfileItem as update
} from '@renderer/utils/ipc' } from '@renderer/utils/ipc'
interface ProfileConfigContextType { interface ProfileConfigContextType {
@ -25,7 +25,8 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
const { data: profileConfig, mutate: mutateProfileConfig } = useSWR('getProfileConfig', () => const { data: profileConfig, mutate: mutateProfileConfig } = useSWR('getProfileConfig', () =>
getProfileConfig() getProfileConfig()
) )
const [targetProfileId, setTargetProfileId] = React.useState<string | null>(null) const targetProfileId = React.useRef<string | null>(null)
const pendingTask = React.useRef<Promise<void> | null>(null)
const setProfileConfig = async (config: IProfileConfig): Promise<void> => { const setProfileConfig = async (config: IProfileConfig): Promise<void> => {
try { try {
@ -72,12 +73,10 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
} }
const changeCurrentProfile = async (id: string): Promise<void> => { const changeCurrentProfile = async (id: string): Promise<void> => {
if (targetProfileId === id) { if (targetProfileId.current === id) {
return return
} }
setTargetProfileId(id)
// 立即更新 UI 状态和托盘菜单,提供即时反馈 // 立即更新 UI 状态和托盘菜单,提供即时反馈
if (profileConfig) { if (profileConfig) {
const optimisticUpdate = { ...profileConfig, current: id } const optimisticUpdate = { ...profileConfig, current: id }
@ -85,17 +84,24 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
window.electron.ipcRenderer.send('updateTrayMenu') window.electron.ipcRenderer.send('updateTrayMenu')
} }
// 异步执行后台切换,不阻塞 UI targetProfileId.current = id
try { await processChange()
await change(id) }
if (targetProfileId === id) { const processChange = async () => {
mutateProfileConfig() if (pendingTask.current) {
setTargetProfileId(null) return
} else { }
}
} catch (e) { while (targetProfileId.current) {
if (targetProfileId === id) { const targetId = targetProfileId.current
targetProfileId.current = null
pendingTask.current = change(targetId)
try {
// 异步执行后台切换,不阻塞 UI
await pendingTask.current
} catch (e) {
const errorMsg = (e as any)?.message || String(e) const errorMsg = (e as any)?.message || String(e)
// 处理 IPC 超时错误 // 处理 IPC 超时错误
if (errorMsg.includes('reply was never sent')) { if (errorMsg.includes('reply was never sent')) {
@ -104,7 +110,8 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child
alert(`切换 Profile 失败: ${errorMsg}`) alert(`切换 Profile 失败: ${errorMsg}`)
mutateProfileConfig() mutateProfileConfig()
} }
setTargetProfileId(null) } finally {
pendingTask.current = null
} }
} }
} }

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": "订阅导入成功",