Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1208420a0 | |||
| 575d1a7008 | |||
| 4d3bb6cfd8 | |||
| 0629ffc3bc | |||
| a6f0d1c73c | |||
| a7c3b544fa |
@@ -0,0 +1,8 @@
|
||||
# Normalize line endings to avoid CRLF issues in Linux containers
|
||||
* text=auto
|
||||
|
||||
# Shell scripts must be LF for correct shebang parsing
|
||||
*.sh text eol=lf
|
||||
|
||||
# PowerShell scripts are typically CRLF on Windows
|
||||
*.ps1 text eol=crlf
|
||||
+2
-1
@@ -10,7 +10,8 @@ RUN apt-get update \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
|
||||
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
+22
-3
@@ -15,6 +15,7 @@ import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } f
|
||||
import ServerPanel from './components/ServerPanel';
|
||||
import StrategySelector from './components/StrategySelector';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
import OverflowMarquee from './components/OverflowMarquee';
|
||||
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages } from 'lucide-react';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
|
||||
@@ -685,7 +686,9 @@ const App: React.FC = () => {
|
||||
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{pathMappingInfo.label}</span>
|
||||
<div className={`flex items-center gap-1.5 text-xs font-medium ${pathMappingInfo.active ? 'text-blue-400' : 'text-gray-600'}`}>
|
||||
<pathMappingInfo.Icon size={12} strokeWidth={2.5} className="flex-shrink-0" />
|
||||
<span className="truncate">{pathMappingInfo.active ? pathMappingInfo.value : t('common.none')}</span>
|
||||
<OverflowMarquee>
|
||||
{pathMappingInfo.active ? pathMappingInfo.value : t('common.none')}
|
||||
</OverflowMarquee>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -694,7 +697,9 @@ const App: React.FC = () => {
|
||||
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${backupInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{backupInfo.label}</span>
|
||||
<div className={`flex items-center gap-1.5 text-xs font-medium ${backupInfo.active ? 'text-indigo-400' : 'text-gray-600'}`}>
|
||||
<Archive size={12} strokeWidth={2.5} className="flex-shrink-0" />
|
||||
<span className="truncate">{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}</span>
|
||||
<OverflowMarquee>
|
||||
{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}
|
||||
</OverflowMarquee>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -713,7 +718,9 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
<div className={`flex items-center gap-1.5 text-xs font-medium mt-0.5 ${scheduleInfo.active ? 'text-green-400' : 'text-gray-600'}`}>
|
||||
<Clock size={12} strokeWidth={2.5} className="flex-shrink-0" />
|
||||
<span className="truncate">{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}</span>
|
||||
<OverflowMarquee>
|
||||
{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}
|
||||
</OverflowMarquee>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -743,6 +750,18 @@ const App: React.FC = () => {
|
||||
>
|
||||
Español
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setLanguage('chs'); setIsLangMenuOpen(false); }}
|
||||
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'chs' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
|
||||
>
|
||||
简体中文
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setLanguage('cht'); setIsLangMenuOpen(false); }}
|
||||
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'cht' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
|
||||
>
|
||||
繁體中文
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
type OverflowMarqueeProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
title?: string;
|
||||
speedPxPerSec?: number;
|
||||
minDurationSec?: number;
|
||||
};
|
||||
|
||||
type MarqueeMetrics = {
|
||||
isOverflowing: boolean;
|
||||
overflowPx: number;
|
||||
durationSec: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SPEED_PX_PER_SEC = 24;
|
||||
const DEFAULT_MIN_DURATION_SEC = 4;
|
||||
|
||||
const OverflowMarquee: React.FC<OverflowMarqueeProps> = ({
|
||||
children,
|
||||
className,
|
||||
textClassName,
|
||||
title,
|
||||
speedPxPerSec = DEFAULT_SPEED_PX_PER_SEC,
|
||||
minDurationSec = DEFAULT_MIN_DURATION_SEC,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [metrics, setMetrics] = useState<MarqueeMetrics>({
|
||||
isOverflowing: false,
|
||||
overflowPx: 0,
|
||||
durationSec: minDurationSec,
|
||||
});
|
||||
|
||||
const fallbackTitle = useMemo(() => {
|
||||
if (title) return title;
|
||||
return typeof children === 'string' ? children : undefined;
|
||||
}, [children, title]);
|
||||
|
||||
const recompute = () => {
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
|
||||
// Ensure we measure with current layout.
|
||||
const available = container.clientWidth;
|
||||
const content = text.scrollWidth;
|
||||
const overflowPx = Math.ceil(content - available);
|
||||
|
||||
if (overflowPx > 1) {
|
||||
const durationSec = Math.max(minDurationSec, overflowPx / Math.max(1, speedPxPerSec));
|
||||
setMetrics({ isOverflowing: true, overflowPx, durationSec });
|
||||
} else {
|
||||
// Avoid re-render loops if already not overflowing.
|
||||
setMetrics((prev) => (prev.isOverflowing ? { isOverflowing: false, overflowPx: 0, durationSec: minDurationSec } : prev));
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
recompute();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [children]);
|
||||
|
||||
useEffect(() => {
|
||||
recompute();
|
||||
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
|
||||
const ro = new ResizeObserver(() => recompute());
|
||||
ro.observe(container);
|
||||
ro.observe(text);
|
||||
|
||||
const onResize = () => recompute();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const textStyle: React.CSSProperties | undefined = metrics.isOverflowing
|
||||
? ({
|
||||
['--marquee-distance' as any]: `${metrics.overflowPx}px`,
|
||||
['--marquee-duration' as any]: `${metrics.durationSec}s`,
|
||||
} satisfies React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={containerRef}
|
||||
className={['overflow-marquee', className].filter(Boolean).join(' ')}
|
||||
title={fallbackTitle}
|
||||
>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={[
|
||||
'overflow-marquee__text',
|
||||
metrics.isOverflowing ? 'overflow-marquee__text--animate' : '',
|
||||
textClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={textStyle}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverflowMarquee;
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Playlist } from '../types';
|
||||
import { Disc3, Clock } from 'lucide-react';
|
||||
import { useLanguage } from '../LanguageContext';
|
||||
import OverflowMarquee from './OverflowMarquee';
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: Playlist;
|
||||
@@ -12,8 +13,10 @@ const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
|
||||
return (
|
||||
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-200 truncate flex-1 mr-2 group-hover:text-white transition-colors">
|
||||
{playlist.title}
|
||||
<h4 className="text-sm font-medium text-gray-200 flex-1 mr-2 group-hover:text-white transition-colors min-w-0">
|
||||
<OverflowMarquee>
|
||||
{playlist.title}
|
||||
</OverflowMarquee>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Playlist, ServerType, PlexServerConnection } from '../types';
|
||||
import PlaylistCard from './PlaylistCard';
|
||||
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
|
||||
import { useLanguage } from '../LanguageContext';
|
||||
import OverflowMarquee from './OverflowMarquee';
|
||||
|
||||
interface ServerPanelProps {
|
||||
type: ServerType;
|
||||
@@ -43,7 +44,12 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
|
||||
displayTitle = serverInfo.name || t('server.cloud');
|
||||
displaySubtitle = (
|
||||
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
|
||||
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
|
||||
<span className="text-plex-orange font-semibold min-w-0 max-w-full">
|
||||
<span className="block md:hidden truncate">{serverInfo.libraryName}</span>
|
||||
<OverflowMarquee className="hidden md:inline-block">
|
||||
{serverInfo.libraryName}
|
||||
</OverflowMarquee>
|
||||
</span>
|
||||
<span className="text-gray-600 hidden md:inline">•</span>
|
||||
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@ const STRATEGIES: StrategyOption[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
||||
const WEEK_DAY_INDEXES = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
// Color Theme Variables for Mapping Editors
|
||||
const MAPPING_THEME = {
|
||||
@@ -818,12 +818,12 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
|
||||
{/* Middle Row: Full Width Capsules */}
|
||||
<div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{WEEK_DAYS.map((day, index) => {
|
||||
const isSelected = localSchedule.weeklyDays.includes(index);
|
||||
{WEEK_DAY_INDEXES.map((dayIndex) => {
|
||||
const isSelected = localSchedule.weeklyDays.includes(dayIndex);
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => toggleWeekDay(index)}
|
||||
key={dayIndex}
|
||||
onClick={() => toggleWeekDay(dayIndex)}
|
||||
className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors
|
||||
first:rounded-l-lg last:rounded-r-lg
|
||||
${isSelected
|
||||
@@ -832,7 +832,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
{t(`schedule.weekdaysNarrow.${dayIndex}`)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -36,6 +36,41 @@
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
/* Overflow marquee (auto-scroll when truncated) */
|
||||
.overflow-marquee {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.overflow-marquee__text {
|
||||
display: inline-block;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.overflow-marquee__text--animate {
|
||||
animation: overflow-marquee-scroll var(--marquee-duration, 6s) linear infinite;
|
||||
}
|
||||
|
||||
@keyframes overflow-marquee-scroll {
|
||||
0%, 12% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
70%, 86% {
|
||||
transform: translateX(calc(var(--marquee-distance, 0px) * -1));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.overflow-marquee__text--animate {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
export const cht = {
|
||||
app: {
|
||||
title: 'PlexSync',
|
||||
manager: '管理',
|
||||
footer: '© {year} PMS Playlist Sync。已連線至 Docker 後端。',
|
||||
},
|
||||
common: {
|
||||
save: '儲存',
|
||||
cancel: '取消',
|
||||
revert: '還原',
|
||||
delete: '刪除',
|
||||
done: '完成',
|
||||
loading: '載入中…',
|
||||
refresh: '重新整理',
|
||||
close: '關閉',
|
||||
none: '無',
|
||||
disabled: '已停用',
|
||||
add: '新增',
|
||||
switchLanguage: '切換語言',
|
||||
},
|
||||
server: {
|
||||
local: '本機伺服器',
|
||||
cloud: '雲端伺服器',
|
||||
playlists: '{count} 個播放清單',
|
||||
notConnected: '未連線',
|
||||
connectionFailed: '連線失敗',
|
||||
connecting: '連線中…',
|
||||
waiting: '等待中…',
|
||||
syncing: '同步中…',
|
||||
noPlaylists: '找不到播放清單。',
|
||||
cancelRefresh: '取消重新整理',
|
||||
refreshPlaylists: '重新整理播放清單',
|
||||
},
|
||||
playlist: {
|
||||
trackCount: '曲目數',
|
||||
lastUpdated: '上次更新',
|
||||
},
|
||||
dashboard: {
|
||||
mapping: '路徑對應',
|
||||
backup: '備份',
|
||||
autoSync: '自動同步',
|
||||
watch: '監看',
|
||||
watchModeActive: '監看模式:啟用',
|
||||
watchModeDisabled: '監看模式:停用',
|
||||
notSet: '未設定',
|
||||
retain: '保留:{count}',
|
||||
keep: '保留 {count}',
|
||||
connected: '已連線至 Plex',
|
||||
disconnected: '未連線',
|
||||
synchronizing: 'SYNCHRONIZING',
|
||||
syncComplete: 'SYNC COMPLETE',
|
||||
},
|
||||
strategies: {
|
||||
title: '同步策略',
|
||||
localOverwrite: {
|
||||
label: '本機覆寫',
|
||||
desc: '本機播放清單完全覆寫雲端。(無 Diff)',
|
||||
},
|
||||
cloudOverwrite: {
|
||||
label: '雲端覆寫',
|
||||
desc: '雲端播放清單完全覆寫本機。(無 Diff)',
|
||||
},
|
||||
mergeLocal: {
|
||||
label: '雙向合併(本機優先)',
|
||||
desc: '合併兩端。衝突以本機版本為準。',
|
||||
},
|
||||
mergeCloud: {
|
||||
label: '雙向合併(雲端優先)',
|
||||
desc: '合併兩端。衝突以雲端版本為準。',
|
||||
},
|
||||
syncNow: '立即同步',
|
||||
syncing: '同步進行中…',
|
||||
saveWarning: '同步前請先儲存待處理的變更(備份/路徑對應)。',
|
||||
},
|
||||
mapping: {
|
||||
title: '路徑對應',
|
||||
simple: '簡易對應',
|
||||
regex: 'Regex 規則',
|
||||
simpleTitle: '路徑對應',
|
||||
simpleSubtitle: '使用簡單字串比對將本機路徑對應到雲端路徑',
|
||||
regexPre: '前處理(同步前)',
|
||||
regexPost: '後處理(同步後 / 結果)',
|
||||
localPath: '本機路徑',
|
||||
cloudPath: '雲端路徑',
|
||||
pattern: '模式',
|
||||
replace: '取代',
|
||||
saveRules: '儲存規則',
|
||||
noRules: '尚未定義規則。',
|
||||
},
|
||||
backup: {
|
||||
title: '備份保留',
|
||||
enable: '啟用備份',
|
||||
enableDesc: '變更前建立副本',
|
||||
maxVersions: '保留的最大版本數:',
|
||||
noAutoDelete: '不自動刪除',
|
||||
autoDelete: '自動刪除最舊版本',
|
||||
},
|
||||
schedule: {
|
||||
title: '排程任務',
|
||||
cron: 'Cron',
|
||||
daily: '每日',
|
||||
weekly: '每週',
|
||||
weekdaysNarrow: {
|
||||
0: '日',
|
||||
1: '一',
|
||||
2: '二',
|
||||
3: '三',
|
||||
4: '四',
|
||||
5: '五',
|
||||
6: '六',
|
||||
},
|
||||
enableCron: '啟用 Cron 排程',
|
||||
enableDaily: '啟用每日執行',
|
||||
enableWeekly: '啟用每週執行',
|
||||
watchLocal: '監看本機變更',
|
||||
watchDesc: '本機播放清單更新時自動同步',
|
||||
schedule: '排程',
|
||||
notConfigured: '尚未設定',
|
||||
today: '今天',
|
||||
tomorrow: '明天',
|
||||
},
|
||||
connection: {
|
||||
titleConnected: '伺服器已連線',
|
||||
titleConnect: '連線 Plex 伺服器',
|
||||
serverDetails: '伺服器詳細資訊',
|
||||
authentication: '驗證',
|
||||
protocol: '通訊協定',
|
||||
address: 'IP 位址或網域',
|
||||
port: '連接埠',
|
||||
token: 'X-Plex-Token(選填)',
|
||||
username: '使用者名稱 / 電子郵件',
|
||||
password: '密碼',
|
||||
advanced: '進階選項',
|
||||
timeout: '連線逾時(秒)',
|
||||
connectBtn: '連線伺服器',
|
||||
connecting: '連線中…',
|
||||
connectedSuccess: '連線成功',
|
||||
selectLibrary: '選擇要同步的媒體庫',
|
||||
},
|
||||
toasts: {
|
||||
localRefreshCancelled: '已取消本機重新整理。',
|
||||
cloudRefreshCancelled: '已取消雲端重新整理。',
|
||||
strategySaved: '已儲存選擇的策略「{strategy}」。',
|
||||
strategySaveFailed: '儲存同步策略失敗。',
|
||||
mappingSaved: '已儲存路徑對應規則。',
|
||||
mappingSaveFailed: '儲存路徑對應規則失敗。',
|
||||
backupSaved: '已儲存備份設定。',
|
||||
backupFailed: '儲存備份設定失敗。',
|
||||
scheduleDisabled: '已停用排程任務。',
|
||||
scheduleEmpty: '已停用排程任務(Cron 為空)。',
|
||||
scheduleStarted: '排程任務更新成功。',
|
||||
scheduleFailed: '更新排程失敗。',
|
||||
syncFailed: '同步失敗。請檢查連線。',
|
||||
backgroundSyncSuccess: '背景同步已成功完成。',
|
||||
backgroundSyncFailed: '背景同步失敗:{error}',
|
||||
librarySwitched: '媒體庫已切換為 {library}',
|
||||
connectedTo: '已成功連線到 {name}',
|
||||
connectionCancelled: '使用者已取消連線。',
|
||||
librarySaveFailed: '儲存媒體庫選擇失敗。',
|
||||
},
|
||||
};
|
||||
@@ -100,6 +100,15 @@ export const en = {
|
||||
cron: 'Cron',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
weekdaysNarrow: {
|
||||
0: 'S',
|
||||
1: 'M',
|
||||
2: 'T',
|
||||
3: 'W',
|
||||
4: 'T',
|
||||
5: 'F',
|
||||
6: 'S',
|
||||
},
|
||||
enableCron: 'Enable Cron Schedule',
|
||||
enableDaily: 'Enable Daily Run',
|
||||
enableWeekly: 'Enable Weekly Run',
|
||||
|
||||
@@ -100,6 +100,15 @@ export const es = {
|
||||
cron: 'Cron',
|
||||
daily: 'Diario',
|
||||
weekly: 'Semanal',
|
||||
weekdaysNarrow: {
|
||||
0: 'D',
|
||||
1: 'L',
|
||||
2: 'M',
|
||||
3: 'X',
|
||||
4: 'J',
|
||||
5: 'V',
|
||||
6: 'S',
|
||||
},
|
||||
enableCron: 'Habilitar Cron',
|
||||
enableDaily: 'Habilitar Ejecución Diaria',
|
||||
enableWeekly: 'Habilitar Ejecución Semanal',
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
export const zh = {
|
||||
app: {
|
||||
title: 'PlexSync',
|
||||
manager: '管理',
|
||||
footer: '© {year} PMS Playlist Sync。已连接到 Docker 后端。',
|
||||
},
|
||||
common: {
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
revert: '恢复',
|
||||
delete: '删除',
|
||||
done: '完成',
|
||||
loading: '加载中...',
|
||||
refresh: '刷新',
|
||||
close: '关闭',
|
||||
none: '无',
|
||||
disabled: '已禁用',
|
||||
add: '添加',
|
||||
switchLanguage: '切换语言',
|
||||
},
|
||||
server: {
|
||||
local: '本地服务器',
|
||||
cloud: '云端服务器',
|
||||
playlists: '{count} 个播放列表',
|
||||
notConnected: '未连接',
|
||||
connectionFailed: '连接失败',
|
||||
connecting: '正在连接...',
|
||||
waiting: '等待中...',
|
||||
syncing: '同步中...',
|
||||
noPlaylists: '未找到播放列表。',
|
||||
cancelRefresh: '取消刷新',
|
||||
refreshPlaylists: '刷新播放列表',
|
||||
},
|
||||
playlist: {
|
||||
trackCount: '曲目数',
|
||||
lastUpdated: '最近更新',
|
||||
},
|
||||
dashboard: {
|
||||
mapping: '路径映射',
|
||||
backup: '备份',
|
||||
autoSync: '自动同步',
|
||||
watch: '监听',
|
||||
watchModeActive: '监听模式:启用',
|
||||
watchModeDisabled: '监听模式:禁用',
|
||||
notSet: '未设置',
|
||||
retain: '保留:{count}',
|
||||
keep: '保留 {count}',
|
||||
connected: '已连接 Plex',
|
||||
disconnected: '未连接',
|
||||
synchronizing: 'SYNCHRONIZING',
|
||||
syncComplete: 'SYNC COMPLETE',
|
||||
},
|
||||
strategies: {
|
||||
title: '同步策略',
|
||||
localOverwrite: {
|
||||
label: '本地覆盖',
|
||||
desc: '本地播放列表完全覆盖云端。(无 Diff)',
|
||||
},
|
||||
cloudOverwrite: {
|
||||
label: '云端覆盖',
|
||||
desc: '云端播放列表完全覆盖本地。(无 Diff)',
|
||||
},
|
||||
mergeLocal: {
|
||||
label: '双向合并(本地优先)',
|
||||
desc: '合并两端。冲突以本地版本为准。',
|
||||
},
|
||||
mergeCloud: {
|
||||
label: '双向合并(云端优先)',
|
||||
desc: '合并两端。冲突以云端版本为准。',
|
||||
},
|
||||
syncNow: '立即同步',
|
||||
syncing: '同步进行中...',
|
||||
saveWarning: '同步前请先保存待处理的更改(备份/路径映射)。',
|
||||
},
|
||||
mapping: {
|
||||
title: '路径映射',
|
||||
simple: '简单映射',
|
||||
regex: '正则规则',
|
||||
simpleTitle: '路径映射',
|
||||
simpleSubtitle: '使用简单字符串匹配将本地路径映射到云端路径',
|
||||
regexPre: '预处理(同步前)',
|
||||
regexPost: '后处理(同步后 / 结果)',
|
||||
localPath: '本地路径',
|
||||
cloudPath: '云端路径',
|
||||
pattern: '模式',
|
||||
replace: '替换',
|
||||
saveRules: '保存规则',
|
||||
noRules: '尚未定义规则。',
|
||||
},
|
||||
backup: {
|
||||
title: '备份保留',
|
||||
enable: '启用备份',
|
||||
enableDesc: '在更改前创建副本',
|
||||
maxVersions: '保留的最大版本数:',
|
||||
noAutoDelete: '不自动删除',
|
||||
autoDelete: '自动删除最旧版本',
|
||||
},
|
||||
schedule: {
|
||||
title: '定时任务',
|
||||
cron: 'Cron',
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
weekdaysNarrow: {
|
||||
0: '日',
|
||||
1: '一',
|
||||
2: '二',
|
||||
3: '三',
|
||||
4: '四',
|
||||
5: '五',
|
||||
6: '六',
|
||||
},
|
||||
enableCron: '启用 Cron 计划',
|
||||
enableDaily: '启用每日运行',
|
||||
enableWeekly: '启用每周运行',
|
||||
watchLocal: '监听本地更改',
|
||||
watchDesc: '本地播放列表更新时自动同步',
|
||||
schedule: '计划',
|
||||
notConfigured: '未配置',
|
||||
today: '今天',
|
||||
tomorrow: '明天',
|
||||
},
|
||||
connection: {
|
||||
titleConnected: '服务器已连接',
|
||||
titleConnect: '连接 Plex 服务器',
|
||||
serverDetails: '服务器详情',
|
||||
authentication: '认证',
|
||||
protocol: '协议',
|
||||
address: 'IP 地址或域名',
|
||||
port: '端口',
|
||||
token: 'X-Plex-Token(可选)',
|
||||
username: '用户名 / 邮箱',
|
||||
password: '密码',
|
||||
advanced: '高级选项',
|
||||
timeout: '连接超时(秒)',
|
||||
connectBtn: '连接服务器',
|
||||
connecting: '连接中...',
|
||||
connectedSuccess: '连接成功',
|
||||
selectLibrary: '选择要同步的媒体库',
|
||||
},
|
||||
toasts: {
|
||||
localRefreshCancelled: '本地刷新已取消。',
|
||||
cloudRefreshCancelled: '云端刷新已取消。',
|
||||
strategySaved: '已保存选择的策略“{strategy}”。',
|
||||
strategySaveFailed: '保存同步策略失败。',
|
||||
mappingSaved: '已保存路径映射规则。',
|
||||
mappingSaveFailed: '保存路径映射规则失败。',
|
||||
backupSaved: '已保存备份设置。',
|
||||
backupFailed: '保存备份设置失败。',
|
||||
scheduleDisabled: '已禁用定时任务。',
|
||||
scheduleEmpty: '已禁用定时任务(Cron 为空)。',
|
||||
scheduleStarted: '定时任务更新成功。',
|
||||
scheduleFailed: '更新定时任务失败。',
|
||||
syncFailed: '同步失败。请检查连接。',
|
||||
backgroundSyncSuccess: '后台同步已成功完成。',
|
||||
backgroundSyncFailed: '后台同步失败:{error}',
|
||||
librarySwitched: '媒体库已切换为 {library}',
|
||||
connectedTo: '已成功连接到 {name}',
|
||||
connectionCancelled: '用户已取消连接。',
|
||||
librarySaveFailed: '保存媒体库选择失败。',
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,13 @@
|
||||
import { en } from './locales/en';
|
||||
import { es } from './locales/es';
|
||||
import { zh as chs } from './locales/zh';
|
||||
import { cht } from './locales/cht';
|
||||
|
||||
export const translations = {
|
||||
en,
|
||||
es,
|
||||
chs,
|
||||
cht,
|
||||
};
|
||||
|
||||
export type Language = keyof typeof translations;
|
||||
|
||||
Reference in New Issue
Block a user