mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
添加繁体转简体,简体转繁体功能
This commit is contained in:
@@ -28,6 +28,9 @@ type User struct {
|
||||
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
||||
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
||||
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
|
||||
// original / simplified / traditional。
|
||||
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
// expires. When set and in the past, the account is treated as expired
|
||||
// (login blocked) until an admin or a redemption code renews it.
|
||||
|
||||
@@ -27,12 +27,13 @@ func NewProfileService(log *zap.Logger, repo *repository.Container) *ProfileServ
|
||||
// ProfileUpdate is the patch object accepted by UpdateProfile. Empty
|
||||
// fields are ignored so the same payload can be reused across screens.
|
||||
type ProfileUpdate struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
HideAdult *bool `json:"hide_adult,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
HideAdult *bool `json:"hide_adult,omitempty"`
|
||||
SubtitleChineseMode *string `json:"subtitle_chinese_mode,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProfile applies a non-credential patch to the user.
|
||||
@@ -73,6 +74,15 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch
|
||||
if patch.HideAdult != nil {
|
||||
updates["hide_adult"] = *patch.HideAdult
|
||||
}
|
||||
if patch.SubtitleChineseMode != nil {
|
||||
mode := strings.ToLower(strings.TrimSpace(*patch.SubtitleChineseMode))
|
||||
switch mode {
|
||||
case "original", "simplified", "traditional":
|
||||
updates["subtitle_chinese_mode"] = mode
|
||||
default:
|
||||
return nil, errors.New("subtitle_chinese_mode must be original, simplified, or traditional")
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||
Updates(updates).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestProfileSubtitleChineseModePersistsAndValidates(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
user := &model.User{
|
||||
Username: "subtitle-viewer",
|
||||
PasswordHash: "hash",
|
||||
Role: "user",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
traditional := "traditional"
|
||||
updated, err := svc.UpdateProfile(t.Context(), user.ID, ProfileUpdate{
|
||||
SubtitleChineseMode: &traditional,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.SubtitleChineseMode != traditional {
|
||||
t.Fatalf("subtitle mode = %q, want %q", updated.SubtitleChineseMode, traditional)
|
||||
}
|
||||
|
||||
invalid := "automatic"
|
||||
if _, err := svc.UpdateProfile(t.Context(), user.ID, ProfileUpdate{
|
||||
SubtitleChineseMode: &invalid,
|
||||
}); err == nil {
|
||||
t.Fatal("invalid subtitle mode should be rejected")
|
||||
}
|
||||
|
||||
reloaded, err := repos.User.FindByID(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.SubtitleChineseMode != traditional {
|
||||
t.Fatalf("invalid update changed subtitle mode to %q", reloaded.SubtitleChineseMode)
|
||||
}
|
||||
}
|
||||
Generated
+7
@@ -14,6 +14,7 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"lucide-react": "^0.379.0",
|
||||
"opencc-js": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -3427,6 +3428,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/opencc-js": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz",
|
||||
"integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==",
|
||||
"license": "MIT AND Apache-2.0"
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.5.11",
|
||||
"lucide-react": "^0.379.0",
|
||||
"opencc-js": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -2,12 +2,15 @@ import { api } from './client'
|
||||
import type { User } from '../types'
|
||||
|
||||
export const profileAPI = {
|
||||
get: () => api.get<User>('/me').then((r) => r.data),
|
||||
|
||||
update: (patch: {
|
||||
username?: string
|
||||
nickname?: string
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
hide_adult?: boolean
|
||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||
password?: string
|
||||
}) =>
|
||||
api.patch<User>('/me', patch).then((r) => r.data),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
VolumeX,
|
||||
} from 'lucide-react'
|
||||
import type { SubtitleTrack } from '../api/subtitles'
|
||||
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
|
||||
|
||||
// PlayerControls — custom bottom control bar replacing the native <video
|
||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||
@@ -36,6 +37,8 @@ type PlayerControlsProps = {
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
onSelectSubtitle: (index: number) => void
|
||||
subtitleChineseMode: SubtitleChineseMode
|
||||
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
|
||||
danmakuOpen: boolean
|
||||
danmakuEnabled: boolean
|
||||
onToggleDanmaku: () => void
|
||||
@@ -63,6 +66,8 @@ export function PlayerControls({
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
subtitleChineseMode,
|
||||
onSubtitleChineseModeChange,
|
||||
danmakuOpen,
|
||||
danmakuEnabled,
|
||||
onToggleDanmaku,
|
||||
@@ -355,6 +360,9 @@ export function PlayerControls({
|
||||
document.pictureInPictureEnabled
|
||||
|
||||
const displayTime = isScrubbing && scrubValue !== null ? scrubValue : currentTime
|
||||
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
const canConvertSelectedSubtitle =
|
||||
selectedSubtitle?.source === 'external' && selectedSubtitle.delivery === 'webvtt'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -448,7 +456,7 @@ export function PlayerControls({
|
||||
)}
|
||||
</button>
|
||||
{subtitleMenuOpen && (
|
||||
<div className="absolute bottom-11 right-0 z-30 min-w-36 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
<div className="absolute bottom-11 right-0 z-30 min-w-44 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -478,6 +486,35 @@ export function PlayerControls({
|
||||
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
|
||||
</button>
|
||||
))}
|
||||
{canConvertSelectedSubtitle && (
|
||||
<div className="mt-1 border-t border-white/10 pt-1">
|
||||
<p className="px-3 py-1 text-[10px] text-white/45">外挂字幕简繁转换</p>
|
||||
{(
|
||||
[
|
||||
['original', '保持原文'],
|
||||
['simplified', '转换为简体'],
|
||||
['traditional', '转换为繁体'],
|
||||
] as const
|
||||
).map(([mode, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSubtitleChineseModeChange(mode)
|
||||
setSubtitleMenuOpen(false)
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
|
||||
subtitleChineseMode === mode ? 'text-rose-400' : 'text-white/85'
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{subtitleChineseMode === mode && (
|
||||
<span className="ml-auto text-rose-400">●</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,15 @@ import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/da
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import { profileAPI } from '../api/profile'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { Media } from '../types'
|
||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
normalizeSubtitleChineseMode,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
||||
import { classifyDirectPlayError } from './directPlayError'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
@@ -51,6 +57,15 @@ export function PlayerPage() {
|
||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
|
||||
const authUser = useAuthStore((state) => state.user)
|
||||
const setAuthUser = useAuthStore((state) => state.setUser)
|
||||
const [subtitleChineseMode, setSubtitleChineseMode] =
|
||||
useState<SubtitleChineseMode>(() =>
|
||||
normalizeSubtitleChineseMode(authUser?.subtitle_chinese_mode),
|
||||
)
|
||||
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
||||
@@ -112,6 +127,24 @@ export function PlayerPage() {
|
||||
.finally(() => setDirectOnlyKnown(true))
|
||||
}, [])
|
||||
|
||||
// 每次进入播放器都从数据库刷新用户的字幕转换偏好;本地登录缓存只用作首屏初值。
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
profileAPI
|
||||
.get()
|
||||
.then((user) => {
|
||||
if (cancelled || subtitlePreferenceTouchedRef.current) return
|
||||
const savedMode = normalizeSubtitleChineseMode(user.subtitle_chinese_mode)
|
||||
persistedSubtitleChineseModeRef.current = savedMode
|
||||
setSubtitleChineseMode(savedMode)
|
||||
setAuthUser(user)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [setAuthUser])
|
||||
|
||||
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
|
||||
useEffect(() => {
|
||||
danmakuAPI
|
||||
@@ -761,6 +794,30 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
|
||||
|
||||
const changeSubtitleChineseMode = useCallback(
|
||||
(nextMode: SubtitleChineseMode) => {
|
||||
subtitlePreferenceTouchedRef.current = true
|
||||
setSubtitleChineseMode(nextMode)
|
||||
|
||||
const save = subtitlePreferenceSaveQueueRef.current.then(async () => {
|
||||
const user = await profileAPI.update({ subtitle_chinese_mode: nextMode })
|
||||
persistedSubtitleChineseModeRef.current = nextMode
|
||||
setAuthUser(user)
|
||||
toast.success('字幕转换偏好已保存,后续播放将自动沿用')
|
||||
})
|
||||
subtitlePreferenceSaveQueueRef.current = save.catch(() => undefined)
|
||||
void save.catch(() => {
|
||||
setSubtitleChineseMode((currentMode) =>
|
||||
currentMode === nextMode
|
||||
? persistedSubtitleChineseModeRef.current
|
||||
: currentMode,
|
||||
)
|
||||
toast.error('字幕转换偏好保存失败,已恢复上次设置')
|
||||
})
|
||||
},
|
||||
[setAuthUser],
|
||||
)
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
const video = ref.current
|
||||
if (mode !== 'direct') {
|
||||
@@ -846,6 +903,8 @@ export function PlayerPage() {
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={selectSubtitle}
|
||||
subtitleChineseMode={subtitleChineseMode}
|
||||
onSubtitleChineseModeChange={changeSubtitleChineseMode}
|
||||
videoRef={ref}
|
||||
onVideoError={handleVideoError}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { PointerEvent, ReactNode, RefObject } from 'react'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||
import type { Media } from '../types'
|
||||
import {
|
||||
loadSubtitleChineseConverter,
|
||||
type SubtitleChineseMode,
|
||||
} from '../utils/subtitleChinese'
|
||||
import { DanmakuStage } from '../components/DanmakuStage'
|
||||
import { PlayerControls } from '../components/PlayerControls'
|
||||
|
||||
@@ -64,6 +68,8 @@ type PlayerVideoStageProps = {
|
||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||
subtitleIndex: number
|
||||
onSelectSubtitle: (index: number) => void
|
||||
subtitleChineseMode: SubtitleChineseMode
|
||||
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
onVideoError: () => void
|
||||
danmakuEnabled: boolean
|
||||
@@ -102,6 +108,8 @@ export function PlayerVideoStage({
|
||||
subs,
|
||||
subtitleIndex,
|
||||
onSelectSubtitle,
|
||||
subtitleChineseMode,
|
||||
onSubtitleChineseModeChange,
|
||||
videoRef,
|
||||
onVideoError,
|
||||
danmakuEnabled,
|
||||
@@ -137,6 +145,8 @@ export function PlayerVideoStage({
|
||||
const revealControlsOnlyRef = useRef(false)
|
||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||
const [activeCueText, setActiveCueText] = useState<string>('')
|
||||
const [subtitleChineseConverter, setSubtitleChineseConverter] =
|
||||
useState<(text: string) => string>(() => (text: string) => text)
|
||||
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
|
||||
// <track>.track.cues;独立时间轴不受 MediaSource 重挂载和轨道 mode 切换影响。
|
||||
const [subtitleTimeline, setSubtitleTimeline] = useState<{
|
||||
@@ -146,6 +156,32 @@ export function PlayerVideoStage({
|
||||
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
|
||||
const [tracksArmed, setTracksArmed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const selectedTrack = subs[subtitleIndex]
|
||||
let cancelled = false
|
||||
|
||||
setSubtitleChineseConverter(() => (text: string) => text)
|
||||
if (
|
||||
subtitleChineseMode === 'original' ||
|
||||
selectedTrack?.source !== 'external' ||
|
||||
selectedTrack.delivery !== 'webvtt'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void loadSubtitleChineseConverter(subtitleChineseMode)
|
||||
.then((converter) => {
|
||||
if (!cancelled) setSubtitleChineseConverter(() => converter)
|
||||
})
|
||||
.catch(() => {
|
||||
// 字典分包加载失败时保留原文,避免影响字幕正常显示。
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [subs, subtitleIndex, subtitleChineseMode])
|
||||
|
||||
useEffect(() => {
|
||||
setTracksArmed(false)
|
||||
const video = videoRef.current
|
||||
@@ -281,7 +317,7 @@ export function PlayerVideoStage({
|
||||
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
||||
.map((cue) => cue.text),
|
||||
)
|
||||
setActiveCueText(texts.join('\n'))
|
||||
setActiveCueText(subtitleChineseConverter(texts.join('\n')))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -315,7 +351,7 @@ export function PlayerVideoStage({
|
||||
}
|
||||
}
|
||||
}
|
||||
setActiveCueText(uniqueSubtitleTexts(texts).join('\n'))
|
||||
setActiveCueText(subtitleChineseConverter(uniqueSubtitleTexts(texts).join('\n')))
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
@@ -370,7 +406,16 @@ export function PlayerVideoStage({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline, tracksArmed])
|
||||
}, [
|
||||
subtitleIndex,
|
||||
subs,
|
||||
videoRef,
|
||||
media,
|
||||
streamOffset,
|
||||
subtitleTimeline,
|
||||
tracksArmed,
|
||||
subtitleChineseConverter,
|
||||
])
|
||||
|
||||
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
||||
const isWiderThanStage =
|
||||
@@ -464,6 +509,8 @@ export function PlayerVideoStage({
|
||||
subs={subs}
|
||||
subtitleIndex={subtitleIndex}
|
||||
onSelectSubtitle={onSelectSubtitle}
|
||||
subtitleChineseMode={subtitleChineseMode}
|
||||
onSubtitleChineseModeChange={onSubtitleChineseModeChange}
|
||||
danmakuOpen={danmakuOpen}
|
||||
danmakuEnabled={danmakuEnabled}
|
||||
onToggleDanmaku={onToggleDanmaku}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface User {
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
hide_adult?: boolean
|
||||
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
allowed_library_ids?: string[]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export type SubtitleChineseMode = 'original' | 'simplified' | 'traditional'
|
||||
|
||||
type ChineseConverter = (text: string) => string
|
||||
|
||||
const identity: ChineseConverter = (text) => text
|
||||
|
||||
export function normalizeSubtitleChineseMode(value: unknown): SubtitleChineseMode {
|
||||
return value === 'simplified' || value === 'traditional' ? value : 'original'
|
||||
}
|
||||
|
||||
let simplifiedConverter: Promise<ChineseConverter> | undefined
|
||||
let traditionalConverter: Promise<ChineseConverter> | undefined
|
||||
|
||||
export function loadSubtitleChineseConverter(
|
||||
mode: SubtitleChineseMode,
|
||||
): Promise<ChineseConverter> {
|
||||
if (mode === 'original') return Promise.resolve(identity)
|
||||
|
||||
if (mode === 'simplified') {
|
||||
simplifiedConverter ??= import('opencc-js/t2cn').then(({ Converter }) =>
|
||||
Converter({ from: 't', to: 'cn' }),
|
||||
)
|
||||
return simplifiedConverter
|
||||
}
|
||||
|
||||
traditionalConverter ??= import('opencc-js/cn2t').then(({ Converter }) =>
|
||||
Converter({ from: 'cn', to: 'tw' }),
|
||||
)
|
||||
return traditionalConverter
|
||||
}
|
||||
Reference in New Issue
Block a user