feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+482
View File
@@ -0,0 +1,482 @@
/**
* 自动排期算法
* 在候选池中选取总时长最接近目标的歌曲组合
*
* 策略:主路径按降序贪心 + 'under' 二次升序回填;辅路径随机采样;取 absDiff 更小的结果
*
* @param direction 'under' 总时长不超过目标,'over' 总时长不低于目标,'middle' 相差最小(中间放)
* @param targetMinutes 目标总时长(分钟)
* @param candidates 候选歌曲列表(需含 id 和 durationSeconds)
* @param preSelected 用户已固定的歌曲(总是保留,算法仅对剩余候选补齐剩余时长)
* @param plansCount 返回方案数,按 absDiff 升序排列(默认 1,仅返回最优)
* @param targetSongCount 期望的最终歌曲数量(可选;启用后优先匹配歌曲数量,再比较时长)
* @param targetRequesterCount 期望的不同投稿人数量(可选;启用后优先覆盖不同投稿人,再比较时长)
*/
export type AutoScheduleDirection = 'under' | 'over' | 'middle'
export interface AutoScheduleCandidate {
id: number
songId: number
title: string
artist: string
durationSeconds?: number | null
replayRequestId?: number | null
musicId?: string | null
musicPlatform?: string | null
requester?: string | null
cover?: string | null
createdAt?: string | null
// UI 标记:是否为固定歌曲
isFixed?: boolean
}
export interface AutoScheduleResult {
songs: AutoScheduleCandidate[]
totalDuration: number
diff: number
absDiff: number
}
function normalizeTargetSongCount(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
return Math.floor(value)
}
/**
* 按投稿人分散排序:每个投稿人的首首歌排前,其余排后(组内保持原时长顺序)
* 用于贪心选曲时优先覆盖不同投稿人
*/
function diversifyOrder(
list: AutoScheduleCandidate[],
targetRequesterCount: number | null
): AutoScheduleCandidate[] {
if (targetRequesterCount === null || targetRequesterCount <= 0) return list
const seen = new Set<string>()
const firstOfRequester: AutoScheduleCandidate[] = []
const rest: AutoScheduleCandidate[] = []
for (const song of list) {
const key = song.requester || ''
if (key && !seen.has(key)) {
seen.add(key)
firstOfRequester.push(song)
} else {
rest.push(song)
}
}
return [...firstOfRequester, ...rest]
}
interface ResultCompareContext {
targetSongCount: number | null
fixedCount: number
targetRequesterCount: number | null
fixedRequesterSet: Set<string>
}
function distinctRequesterCount(
songs: AutoScheduleCandidate[],
ctx: ResultCompareContext
): number {
const set = new Set(ctx.fixedRequesterSet)
for (const s of songs) {
if (s.requester) set.add(s.requester)
}
return set.size
}
function compareResults(a: AutoScheduleResult, b: AutoScheduleResult, ctx: ResultCompareContext): number {
if (ctx.targetSongCount !== null) {
const aCountDiff = Math.abs(a.songs.length + ctx.fixedCount - ctx.targetSongCount)
const bCountDiff = Math.abs(b.songs.length + ctx.fixedCount - ctx.targetSongCount)
if (aCountDiff !== bCountDiff) return aCountDiff - bCountDiff
}
if (ctx.targetRequesterCount !== null) {
const aReqDiff = Math.abs(distinctRequesterCount(a.songs, ctx) - ctx.targetRequesterCount)
const bReqDiff = Math.abs(distinctRequesterCount(b.songs, ctx) - ctx.targetRequesterCount)
if (aReqDiff !== bReqDiff) return aReqDiff - bReqDiff
}
return a.absDiff - b.absDiff || b.songs.length - a.songs.length
}
/**
* 公共前置处理:计算目标秒数、排除固定歌曲、准备候选池
* 若无可用的候选歌曲,直接返回 earlyReturn 供调用方提前结束
*/
function prepareScheduleInput(
targetMinutes: number,
candidates: AutoScheduleCandidate[],
preSelected: AutoScheduleCandidate[],
plansCount: number
): {
targetSeconds: number
preSelectedSeconds: number
availableCandidates: AutoScheduleCandidate[]
emptyResult: AutoScheduleResult
earlyReturn?: AutoScheduleResult | AutoScheduleResult[]
} {
const targetSeconds = Math.floor(targetMinutes * 60)
const preSelectedIds = new Set(preSelected.map((s) => s.id))
const preSelectedSeconds = preSelected.reduce(
(sum, s) => sum + (typeof s.durationSeconds === 'number' ? s.durationSeconds : 0),
0
)
const availableCandidates = candidates.filter(
(s) => !preSelectedIds.has(s.id) && typeof s.durationSeconds === 'number' && s.durationSeconds > 0
)
const emptyResult: AutoScheduleResult = { songs: [], totalDuration: 0, diff: 0, absDiff: 0 }
if (availableCandidates.length === 0 && preSelected.length === 0) {
return { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult, earlyReturn: plansCount === 1 ? emptyResult : [emptyResult] }
}
if (availableCandidates.length === 0) {
const fixedSongs = preSelected.map((s) => ({ ...s, isFixed: true }))
const total = preSelectedSeconds
const diff = total - targetSeconds
const result: AutoScheduleResult = { songs: fixedSongs, totalDuration: total, diff, absDiff: Math.abs(diff) }
return { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult, earlyReturn: plansCount === 1 ? result : [result] }
}
return { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult }
}
export function autoSchedule(
direction: AutoScheduleDirection,
targetMinutes: number,
candidates: AutoScheduleCandidate[],
preSelected: AutoScheduleCandidate[] = [],
plansCount: number = 1,
targetSongCount?: number | null,
targetRequesterCount?: number | null
): AutoScheduleResult | AutoScheduleResult[] {
const input = prepareScheduleInput(targetMinutes, candidates, preSelected, plansCount)
if (input.earlyReturn !== undefined) return input.earlyReturn
const { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult } = input
const normalizedTargetSongCount = normalizeTargetSongCount(targetSongCount)
const normalizedTargetRequesterCount = normalizeTargetSongCount(targetRequesterCount)
const compareCtx: ResultCompareContext = {
targetSongCount: normalizedTargetSongCount,
fixedCount: preSelected.length,
targetRequesterCount: normalizedTargetRequesterCount,
fixedRequesterSet: new Set(preSelected.map((s) => s.requester).filter(Boolean))
}
const targetAdditionalCount = normalizedTargetSongCount === null
? null
: Math.max(0, normalizedTargetSongCount - preSelected.length)
const remainingTarget = Math.max(0, targetSeconds - preSelectedSeconds)
const songMap = new Map(availableCandidates.map((s) => [s.id, s]))
const runGreedy = (sorted: AutoScheduleCandidate[], dir: 'under' | 'over') => {
const result: AutoScheduleCandidate[] = []
let total = 0
for (const song of sorted) {
if (targetAdditionalCount !== null && result.length >= targetAdditionalCount) break
const newTotal = total + song.durationSeconds!
if (dir === 'under') {
if (newTotal <= remainingTarget) {
result.push(song)
total = newTotal
}
} else {
if (remainingTarget <= 0) {
// 已选歌曲时长已超过目标,无需再追加
} else {
result.push(song)
total = newTotal
if (
total >= remainingTarget &&
(targetAdditionalCount === null || result.length >= targetAdditionalCount)
) break
}
}
}
if (dir === 'under') {
const ids = new Set(result.map((s) => s.id))
for (const song of diversifyOrder(
[...availableCandidates].sort((a, b) => a.durationSeconds! - b.durationSeconds!),
normalizedTargetRequesterCount
)) {
if (targetAdditionalCount !== null && result.length >= targetAdditionalCount) break
if (ids.has(song.id)) continue
if (total + song.durationSeconds! <= remainingTarget) {
result.push(song)
total += song.durationSeconds!
ids.add(song.id)
}
}
} else {
const selectedIds = new Set(result.map((s) => s.id))
let remaining = availableCandidates.filter((s) => !selectedIds.has(s.id))
.sort((a, b) => a.durationSeconds! - b.durationSeconds!)
let ri = result.length - 1
search: while (ri >= 0) {
const currentSong = result[ri]
const currentDuration = currentSong.durationSeconds!
const totalWithoutThis = total - currentDuration
// 移除当前歌后仍满足约束,直接移除(更优解)
if (
totalWithoutThis >= remainingTarget &&
(targetAdditionalCount === null || result.length > targetAdditionalCount)
) {
result.splice(ri, 1)
total = totalWithoutThis
selectedIds.delete(currentSong.id)
remaining = availableCandidates.filter((s) => !selectedIds.has(s.id))
.sort((a, b) => a.durationSeconds! - b.durationSeconds!)
ri = result.length - 1
continue
}
const minReplacement = remainingTarget - totalWithoutThis
if (minReplacement >= currentDuration) {
ri--
continue
}
for (let ii = 0; ii < remaining.length; ii++) {
const candidate = remaining[ii]
if (
candidate.durationSeconds! >= minReplacement &&
candidate.durationSeconds! < currentDuration
) {
result[ri] = candidate
total = totalWithoutThis + candidate.durationSeconds!
selectedIds.delete(currentSong.id)
selectedIds.add(candidate.id)
remaining.splice(ii, 1)
break search
}
}
ri--
}
}
const diff = (total + preSelectedSeconds) - targetSeconds
return { songs: result, totalDuration: total + preSelectedSeconds, diff, absDiff: Math.abs(diff) }
}
const sortedDescending = [...availableCandidates].sort(
(a, b) => b.durationSeconds! - a.durationSeconds!
)
const sortedAscending = [...availableCandidates].sort(
(a, b) => a.durationSeconds! - b.durationSeconds!
)
// 设置投稿人目标时分散排序,优先覆盖不同投稿人
const diversifiedDescending = diversifyOrder(sortedDescending, normalizedTargetRequesterCount)
const diversifiedAscending = diversifyOrder(sortedAscending, normalizedTargetRequesterCount)
const sortedByDuration = normalizedTargetSongCount !== null && direction === 'under'
? diversifiedAscending
: diversifiedDescending
// 生成方案
const plans: AutoScheduleResult[] = []
const seenSets = new Set<string>()
const addUnique = (result: AutoScheduleResult) => {
const key = result.songs.map((s) => s.id).sort().join(',')
if (!seenSets.has(key)) {
seenSets.add(key)
plans.push(result)
}
}
// 确定性路径
if (direction === 'middle') {
addUnique(runGreedy(normalizedTargetSongCount !== null ? diversifiedAscending : diversifiedDescending, 'under'))
addUnique(runGreedy(diversifiedDescending, 'over'))
} else {
addUnique(runGreedy(sortedByDuration, direction))
}
// 随机路径:多次 shuffle 生成不同方案
// 生成比 plansCount 多一些以保留筛选空间
const maxIter = Math.max(50, plansCount * 5)
for (let i = 0; i < maxIter && plans.length < plansCount * 3; i++) {
const shuffled = availableCandidates.map((s) => s.id)
for (let j = shuffled.length - 1; j > 0; j--) {
const k = Math.floor(Math.random() * (j + 1))
;[shuffled[j], shuffled[k]] = [shuffled[k], shuffled[j]]
}
const shuffledSorted = shuffled.map((id) => songMap.get(id)).filter(Boolean)
const dir = direction === 'middle' ? (i % 2 === 0 ? 'under' : 'over') : direction
addUnique(runGreedy(diversifyOrder(shuffledSorted, normalizedTargetRequesterCount), dir))
}
// 按质量排序:设置期望首数时先比较首数偏差,再比较时长偏差
plans.sort((a, b) => compareResults(a, b, compareCtx))
// 合并固定歌曲并截取
const fixedSongs = preSelected.map((s) => ({ ...s, isFixed: true }))
const finalPlans = plans
.slice(0, plansCount)
.map((p) => ({ ...p, songs: [...fixedSongs, ...p.songs] }))
if (plansCount === 1) {
return finalPlans[0] || emptyResult
}
return finalPlans
}
/**
* 穷举排期算法(DFS + 剪枝)
* 系统遍历候选歌曲的子集组合,按 absDiff 收集最优方案
*
* 复杂度:最坏 O(2^n),通过剪枝大幅缩减实际搜索空间
* - 方向约束剪枝:'under' 超过目标立即终止;'over' 无法达到目标终止
* - 最优解剪枝:当前分支最差情况已超过已有最优解则终止
* - 节点上限:限制总访问节点数,防止超大候选池卡死
*/
export function autoScheduleExhaustive(
direction: AutoScheduleDirection,
targetMinutes: number,
candidates: AutoScheduleCandidate[],
preSelected: AutoScheduleCandidate[] = [],
plansCount: number = 1,
targetSongCount?: number | null,
targetRequesterCount?: number | null
): AutoScheduleResult | AutoScheduleResult[] {
const input = prepareScheduleInput(targetMinutes, candidates, preSelected, plansCount)
if (input.earlyReturn !== undefined) return input.earlyReturn
const { targetSeconds, preSelectedSeconds, availableCandidates, emptyResult } = input
const normalizedTargetSongCount = normalizeTargetSongCount(targetSongCount)
const normalizedTargetRequesterCount = normalizeTargetSongCount(targetRequesterCount)
const compareCtx: ResultCompareContext = {
targetSongCount: normalizedTargetSongCount,
fixedCount: preSelected.length,
targetRequesterCount: normalizedTargetRequesterCount,
fixedRequesterSet: new Set(preSelected.map((s) => s.requester).filter(Boolean))
}
// 将时长收窄为 number 类型,消除后续的非空断言
type DurationCandidate = Omit<AutoScheduleCandidate, 'durationSeconds'> & { durationSeconds: number }
const remainingTarget = Math.max(0, targetSeconds - preSelectedSeconds)
// 按时长降序排列,大歌曲优先,利于剪枝;设置投稿人目标时分散排序
const sorted = diversifyOrder(
[...(availableCandidates as DurationCandidate[])].sort((a, b) => b.durationSeconds - a.durationSeconds),
normalizedTargetRequesterCount
)
const n = sorted.length
// 后缀和:suffixSum[i] = sorted[i..n-1] 的时长之和,用于判定能否达到目标
const suffixSum = new Array(n + 1).fill(0)
for (let i = n - 1; i >= 0; i--) {
suffixSum[i] = suffixSum[i + 1] + sorted[i].durationSeconds
}
const solutions: AutoScheduleResult[] = []
const seenKeys = new Set<string>()
let nodeCount = 0
const maxNodes = 100000
const maxSolutions = plansCount * 3
// worstAbs 记录当前已收录方案中最差的 absDiff,作为剪枝阈值
// 初始值 Infinity 确保首个方案能被正确记录并更新该值(0 会导致找到完美解后后续方案被全部过滤)
let worstAbs = Infinity
const currentSongs: AutoScheduleCandidate[] = []
const record = (currentTotal: number) => {
const canUseFixedOnly = normalizedTargetSongCount !== null && preSelected.length >= normalizedTargetSongCount
if (currentSongs.length === 0 && !canUseFixedOnly) return
const total = currentTotal + preSelectedSeconds
const diff = total - targetSeconds
if (direction === 'over' && total < targetSeconds) return
if (direction === 'under' && total > targetSeconds) return
const absDiff = Math.abs(diff)
const key = currentSongs.map((s) => s.id).sort().join(',')
if (seenKeys.has(key)) return
seenKeys.add(key)
solutions.push({ songs: [...currentSongs], totalDuration: total, diff, absDiff })
solutions.sort((a, b) => compareResults(a, b, compareCtx))
if (solutions.length > maxSolutions) {
solutions.splice(maxSolutions)
worstAbs = solutions[solutions.length - 1].absDiff
}
}
const canPrune = (idx: number, currentTotal: number): boolean => {
if (nodeCount > maxNodes) return true
if (direction === 'under') {
// 已超过目标,无效
if (currentTotal > remainingTarget) return true
// 即使加上所有剩余歌曲也到不了目标,且当前差距已大于已有最优
if (normalizedTargetSongCount === null && currentTotal + suffixSum[idx] < remainingTarget && remainingTarget - currentTotal > worstAbs) {
return true
}
} else if (direction === 'over') {
// 即使加上所有剩余歌曲也达不到目标
if (currentTotal + suffixSum[idx] < remainingTarget) return true
// 当前已远超目标且超出量已大于已有最优
if (normalizedTargetSongCount === null && currentTotal >= remainingTarget && currentTotal - remainingTarget > worstAbs) {
return true
}
} else {
// middle:判断从当前状态能达到的最好 absDiff
const minDiff = currentTotal - remainingTarget
const maxDiff = currentTotal + suffixSum[idx] - remainingTarget
if (normalizedTargetSongCount === null) {
if (minDiff >= 0 && minDiff > worstAbs) return true
if (maxDiff <= 0 && -maxDiff > worstAbs) return true
}
}
return false
}
const dfs = (idx: number, currentTotal: number) => {
nodeCount++
if (nodeCount > maxNodes) return
record(currentTotal)
if (idx >= n) return
if (canPrune(idx, currentTotal)) return
// 尝试包含 sorted[idx]
if (direction === 'under') {
if (currentTotal + sorted[idx].durationSeconds <= remainingTarget) {
currentSongs.push(sorted[idx])
dfs(idx + 1, currentTotal + sorted[idx].durationSeconds)
currentSongs.pop()
}
} else {
currentSongs.push(sorted[idx])
dfs(idx + 1, currentTotal + sorted[idx].durationSeconds)
currentSongs.pop()
}
// 尝试不包含 sorted[idx]
dfs(idx + 1, currentTotal)
}
dfs(0, 0)
solutions.sort((a, b) => compareResults(a, b, compareCtx))
const fixedSongs = preSelected.map((s) => ({ ...s, isFixed: true }))
const finalPlans = solutions.slice(0, plansCount).map((p) => ({
...p,
songs: [...fixedSongs, ...p.songs]
}))
return plansCount === 1 ? (finalPlans[0] || emptyResult) : finalPlans
}
/**
* 将备选池条目规范化为统一的候选歌曲对象
*/
export function poolCandidateFromItem(item: any): AutoScheduleCandidate {
return {
id: item.songId,
songId: item.songId,
title: item.title,
artist: item.artist,
durationSeconds: item.durationSeconds,
replayRequestId: item.replayRequestId || null,
musicId: item.musicId,
musicPlatform: item.musicPlatform,
requester: item.requester,
cover: item.cover,
createdAt: item.createdAt
}
}
+72
View File
@@ -0,0 +1,72 @@
export interface ParsedBilibiliId {
bvid: string
cid?: string
}
export function parseBilibiliId(id: string | number): ParsedBilibiliId {
const idStr = String(id)
if (idStr.includes(':')) {
const parts = idStr.split(':')
return {
bvid: parts[0],
cid: parts[1]
}
}
return {
bvid: idStr
}
}
/**
* 判断是否为哔哩哔哩歌曲
* @param song 歌曲对象
* @returns boolean
*/
export function isBilibiliSong(song: any): boolean {
if (!song) return false
// 如果有手动填入的 playUrl,则不认为是哔哩哔哩歌曲
// 因为手动投稿的歌曲可能来自任何平台,应该直接使用 playUrl 播放
if (song.playUrl && song.playUrl.trim()) {
return false
}
const musicPlatform = song.musicPlatform || song.actualMusicPlatform || ''
const musicId = String(song.musicId || song.id || '')
return (
musicPlatform === 'bilibili' ||
musicId.startsWith('BV') ||
musicId.startsWith('av')
)
}
export async function getBilibiliTrackUrl(id: string, cid?: string) {
try {
const params: any = { id }
if (cid) {
params.cid = cid
}
const data = await $fetch('/api/bilibili/playurl', {
params
})
return data
} catch (error) {
console.error('Bilibili track url error:', error)
throw error
}
}
export async function searchBilibili(keyword: string) {
try {
const data = await $fetch<any[]>('/api/bilibili/search', {
params: { keyword }
})
return data || []
} catch (error) {
console.error('Bilibili search error:', error)
return []
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* 歌曲类型黑名单前端共享模块(单一来源)
* 存储值使用规范中文标签,与服务端匹配口径一致
* 新增候选值需同步:本文件 + server/config/constants.ts 的 BLACKLIST_LANGUAGE_VALUES / BLACKLIST_GENRE_VALUES
*/
export const BLACKLIST_LANGUAGE_VALUES = ['华语', '粤语', '闽南语', '英语', '日语', '韩语', '其他'] as const
export const BLACKLIST_GENRE_VALUES = [
'流行', '摇滚', '民谣', '电子', '舞曲', '说唱', '古典', '爵士',
'乡村', '原声带', '蓝调', '轻音乐', '其他'
] as const
// 英文界面显示名(键为存储值;中文界面直接显示存储值)
export const BLACKLIST_LANGUAGE_LABEL_EN: Record<string, string> = {
华语: 'Mandarin',
粤语: 'Cantonese',
闽南语: 'Hokkien',
英语: 'English',
日语: 'Japanese',
韩语: 'Korean',
其他: 'Other'
}
export const BLACKLIST_GENRE_LABEL_EN: Record<string, string> = {
流行: 'Pop',
摇滚: 'Rock',
民谣: 'Folk',
电子: 'Electronic',
舞曲: 'Dance',
说唱: 'Rap',
古典: 'Classical',
爵士: 'Jazz',
乡村: 'Country',
原声带: 'Soundtrack',
蓝调: 'Blues',
轻音乐: 'Easy Listening',
其他: 'Other'
}
+10
View File
@@ -0,0 +1,10 @@
export const _sig = [76, 97, 111, 83, 104, 117, 105]
export const _sys = [86, 111, 105, 99, 101, 72, 117, 98]
export const _repo = [
104, 116, 116, 112, 115, 58, 47, 47, 103, 105, 116, 104, 117, 98, 46, 99, 111, 109, 47, 108, 97,
111, 115, 104, 117, 105, 107, 97, 105, 120, 117, 101, 47, 86, 111, 105, 99, 101, 72, 117, 98
]
export const getCopyrightOwner = () => String.fromCharCode(..._sig)
export const getSystemName = () => String.fromCharCode(..._sys)
export const getRepoUrl = () => String.fromCharCode(..._repo)
+546
View File
@@ -0,0 +1,546 @@
/**
* 繁体 → 简体 单字映射表(自动生成,请勿手工编辑)
*
* 数据来源: OpenCC data/dictionary/TSCharacters.txt
* (https://github.com/BYVoid/OpenCC, Apache-2.0 License)
* 多候选值仅取第一项;同形字(繁简同字)不收录。
*/
export const TRAD_TO_SIMP: Record<string, string> = {
'㑯': '㑔', '㑳': '㑇', '㑶': '㐹', '㓨': '刾', '㗲': "\u{20D7E}", '㘚': '㘎',
'㜄': '㚯', '㜏': '㛣', '㜢': "\u{217B1}", '㠏': '㟆', '㠣': "\u{2BD77}", '㥮': '㤘',
'㩜': '㨫', '㩳': '㧐', '㩵': '擜', '㺏': "\u{2480B}", '䁪': "\u{251E2}", '䁻': '䀥',
'䃮': '鿎', '䊷': '䌶', '䋙': '䌺', '䋚': '䌻', '䋹': '䌿', '䋻': '䌾',
'䍦': '䍠', '䎱': '䎬', '䓣': "\u{2C72F}", '䙡': '䙌', '䜀': '䜧', '䝼': '䞍',
'䡵': "\u{2B7E6}", '䥇': '䦂', '䥑': '鿏', '䥕': "\u{2CB6F}", '䥱': '䥾', '䦛': '䦶',
'䦟': '䦷', '䧢': "\u{28E1F}", '䮄': "\u{2B80A}", '䯀': '䯅', '䰾': '鲃', '䱷': '䲣',
'䱽': '䲝', '䲁': '鳚', '䲘': '鳤', '䴉': '鹮', '丟': '丢', '並': '并',
'乾': '干', '亂': '乱', '亙': '亘', '亞': '亚', '佇': '伫', '佈': '布',
'佔': '占', '併': '并', '來': '来', '侖': '仑', '侶': '侣', '侷': '局',
'俁': '俣', '係': '系', '俔': '伣', '俠': '侠', '俥': '伡', '俬': '私',
'倀': '伥', '倆': '俩', '倈': '俫', '倉': '仓', '個': '个', '們': '们',
'倖': '幸', '倫': '伦', '倲': '㑈', '偉': '伟', '偑': '㐽', '側': '侧',
'偵': '侦', '偽': '伪', '傌': '㐷', '傑': '杰', '傖': '伧', '傘': '伞',
'備': '备', '傢': '家', '傭': '佣', '傯': '偬', '傳': '传', '傴': '伛',
'債': '债', '傷': '伤', '傾': '倾', '僂': '偻', '僅': '仅', '僉': '佥',
'僑': '侨', '僕': '仆', '僞': '伪', '僤': "\u{2B8B8}", '僥': '侥', '僨': '偾',
'僱': '雇', '價': '价', '儀': '仪', '儁': '俊', '儂': '侬', '億': '亿',
'儈': '侩', '儉': '俭', '儎': '傤', '儐': '傧', '儔': '俦', '儕': '侪',
'儘': '尽', '償': '偿', '優': '优', '儲': '储', '儷': '俪', '儸': '㑩',
'儺': '傩', '儻': '傥', '儼': '俨', '兇': '凶', '兌': '兑', '兒': '儿',
'兗': '兖', '內': '内', '兩': '两', '冊': '册', '冑': '胄', '冪': '幂',
'凈': '净', '凍': '冻', '凜': '凛', '凱': '凯', '別': '别', '刪': '删',
'剄': '刭', '則': '则', '剋': '克', '剎': '刹', '剗': '刬', '剛': '刚',
'剝': '剥', '剮': '剐', '剴': '剀', '創': '创', '剷': '铲', '劃': '划',
'劄': '札', '劇': '剧', '劉': '刘', '劊': '刽', '劌': '刿', '劍': '剑',
'劏': '㓥', '劑': '剂', '劚': '㔉', '勁': '劲', '動': '动', '務': '务',
'勛': '勋', '勝': '胜', '勞': '劳', '勢': '势', '勣': "\u{2A7DD}", '勩': '勚',
'勱': '劢', '勳': '勋', '勵': '励', '勸': '劝', '勻': '匀', '匭': '匦',
'匯': '汇', '匱': '匮', '區': '区', '協': '协', '卹': '恤', '卻': '却',
'卽': '即', '厙': '厍', '厠': '厕', '厤': '历', '厭': '厌', '厲': '厉',
'厴': '厣', '參': '参', '叄': '叁', '叢': '丛', '吒': '咤', '吳': '吴',
'吶': '呐', '呂': '吕', '咼': '呙', '員': '员', '唄': '呗', '唸': '念',
'問': '问', '啓': '启', '啞': '哑', '啟': '启', '啢': '唡', '喎': '㖞',
'喚': '唤', '喪': '丧', '喫': '吃', '喬': '乔', '單': '单', '喲': '哟',
'嗆': '呛', '嗇': '啬', '嗊': '唝', '嗎': '吗', '嗚': '呜', '嗩': '唢',
'嗰': "\u{20BB6}", '嗶': '哔', '嘆': '叹', '嘍': '喽', '嘓': '啯', '嘔': '呕',
'嘖': '啧', '嘗': '尝', '嘜': '唛', '嘩': '哗', '嘮': '唠', '嘯': '啸',
'嘰': '叽', '嘵': '哓', '嘸': '呒', '嘽': '啴', '噁': '恶', '噓': '嘘',
'噚': '㖊', '噝': '咝', '噠': '哒', '噥': '哝', '噦': '哕', '噯': '嗳',
'噲': '哙', '噴': '喷', '噸': '吨', '噹': '当', '嚀': '咛', '嚇': '吓',
'嚌': '哜', '嚐': '尝', '嚕': '噜', '嚙': '啮', '嚥': '咽', '嚦': '呖',
'嚧': "\u{20C37}", '嚨': '咙', '嚮': '向', '嚲': '亸', '嚳': '喾', '嚴': '严',
'嚶': '嘤', '囀': '啭', '囁': '嗫', '囂': '嚣', '囅': '冁', '囈': '呓',
'囉': '啰', '囌': '苏', '囑': '嘱', '囪': '囱', '圇': '囵', '國': '国',
'圍': '围', '園': '园', '圓': '圆', '圖': '图', '團': '团', '垻': '坝',
'埡': '垭', '埨': "\u{2BB62}", '埰': '采', '執': '执', '堅': '坚', '堊': '垩',
'堖': '垴', '堝': '埚', '堯': '尧', '報': '报', '場': '场', '塊': '块',
'塋': '茔', '塏': '垲', '塒': '埘', '塗': '涂', '塚': '冢', '塢': '坞',
'塤': '埙', '塵': '尘', '塸': "\u{2BB5F}", '塹': '堑', '塿': "\u{2A8FB}", '墊': '垫',
'墜': '坠', '墠': "\u{2BB83}", '墮': '堕', '墰': '坛', '墳': '坟', '墶': '垯',
'墻': '墙', '墾': '垦', '壇': '坛', '壋': '垱', '壎': '埙', '壓': '压',
'壗': "\u{212E4}", '壘': '垒', '壙': '圹', '壚': '垆', '壜': '坛', '壞': '坏',
'壟': '垄', '壠': '垅', '壢': '坜', '壩': '坝', '壪': '塆', '壯': '壮',
'壺': '壶', '壼': '壸', '壽': '寿', '夠': '够', '夢': '梦', '夥': '伙',
'夾': '夹', '奐': '奂', '奧': '奥', '奩': '奁', '奪': '夺', '奬': '奖',
'奮': '奋', '奼': '姹', '妝': '妆', '姍': '姗', '姦': '奸', '娙': "\u{2BC1B}",
'娛': '娱', '婁': '娄', '婦': '妇', '婭': '娅', '媧': '娲', '媯': '妫',
'媰': '㛀', '媼': '媪', '媽': '妈', '嫋': '袅', '嫗': '妪', '嫵': '妩',
'嫺': '娴', '嫻': '娴', '嫿': '婳', '嬀': '妫', '嬃': '媭', '嬈': '娆',
'嬋': '婵', '嬌': '娇', '嬙': '嫱', '嬡': '嫒', '嬤': '嬷', '嬪': '嫔',
'嬰': '婴', '嬸': '婶', '孃': '娘', '孋': '㛤', '孌': '娈', '孫': '孙',
'學': '学', '孻': "\u{21967}", '孿': '孪', '宮': '宫', '寀': '采', '寢': '寝',
'實': '实', '寧': '宁', '審': '审', '寫': '写', '寬': '宽', '寵': '宠',
'寶': '宝', '將': '将', '專': '专', '尋': '寻', '對': '对', '導': '导',
'尷': '尴', '屆': '届', '屍': '尸', '屓': '屃', '屜': '屉', '屢': '屡',
'層': '层', '屨': '屦', '屬': '属', '岡': '冈', '峯': '峰', '峴': '岘',
'島': '岛', '峽': '峡', '崍': '崃', '崑': '昆', '崗': '岗', '崙': '仑',
'崢': '峥', '崬': '岽', '嵐': '岚', '嵗': '岁', '嵽': "\u{2BD87}", '嵾': '㟥',
'嶁': '嵝', '嶄': '崭', '嶇': '岖', '嶔': '嵚', '嶗': '崂', '嶠': '峤',
'嶢': '峣', '嶧': '峄', '嶨': '峃', '嶮': '崄', '嶸': '嵘', '嶺': '岭',
'嶼': '屿', '嶽': '岳', '巋': '岿', '巒': '峦', '巔': '巅', '巖': '岩',
'巘': "\u{2AA58}", '巰': '巯', '巹': '卺', '帥': '帅', '師': '师', '帳': '帐',
'帶': '带', '幀': '帧', '幃': '帏', '幓': '㡎', '幗': '帼', '幘': '帻',
'幟': '帜', '幣': '币', '幫': '帮', '幬': '帱', '幷': '并', '幹': '干',
'幾': '几', '庫': '库', '廁': '厕', '廂': '厢', '廄': '厩', '廈': '厦',
'廎': '庼', '廕': '荫', '廚': '厨', '廝': '厮', '廞': "\u{2BDF7}", '廟': '庙',
'廠': '厂', '廡': '庑', '廢': '废', '廣': '广', '廩': '廪', '廬': '庐',
'廳': '厅', '弒': '弑', '弔': '吊', '弳': '弪', '張': '张', '強': '强',
'彄': "\u{2BE29}", '彆': '别', '彈': '弹', '彌': '弥', '彎': '弯', '彔': '录',
'彙': '汇', '彠': '彟', '彥': '彦', '彫': '雕', '彲': '彨', '彿': '佛',
'後': '后', '徑': '径', '從': '从', '徠': '徕', '復': '复', '徵': '征',
'徹': '彻', '恆': '恒', '恥': '耻', '悅': '悦', '悞': '悮', '悵': '怅',
'悶': '闷', '悽': '凄', '惡': '恶', '惱': '恼', '惲': '恽', '惻': '恻',
'愛': '爱', '愜': '惬', '愨': '悫', '愴': '怆', '愷': '恺', '愾': '忾',
'慄': '栗', '態': '态', '慍': '愠', '慘': '惨', '慚': '惭', '慟': '恸',
'慣': '惯', '慤': '悫', '慪': '怄', '慫': '怂', '慮': '虑', '慳': '悭',
'慶': '庆', '慺': '㥪', '慼': '戚', '慾': '欲', '憂': '忧', '憊': '惫',
'憐': '怜', '憑': '凭', '憒': '愦', '憖': '慭', '憚': '惮', '憤': '愤',
'憫': '悯', '憮': '怃', '憲': '宪', '憶': '忆', '懇': '恳', '應': '应',
'懌': '怿', '懍': '懔', '懞': '蒙', '懟': '怼', '懣': '懑', '懤': '㤽',
'懨': '恹', '懲': '惩', '懶': '懒', '懷': '怀', '懸': '悬', '懺': '忏',
'懼': '惧', '懾': '慑', '戀': '恋', '戇': '戆', '戔': '戋', '戧': '戗',
'戩': '戬', '戰': '战', '戱': '戯', '戲': '戏', '戶': '户', '扞': '捍',
'拋': '抛', '拚': '拼', '挩': '捝', '挱': '挲', '挾': '挟', '捨': '舍',
'捫': '扪', '捱': '挨', '捲': '卷', '掃': '扫', '掄': '抡', '掆': '㧏',
'掗': '挜', '掙': '挣', '掛': '挂', '採': '采', '揀': '拣', '揚': '扬',
'換': '换', '揮': '挥', '揯': '搄', '損': '损', '搖': '摇', '搗': '捣',
'搧': '扇', '搵': '揾', '搶': '抢', '摑': '掴', '摜': '掼', '摟': '搂',
'摯': '挚', '摳': '抠', '摶': '抟', '摺': '折', '摻': '掺', '撈': '捞',
'撏': '挦', '撐': '撑', '撓': '挠', '撝': '㧑', '撟': '挢', '撣': '掸',
'撥': '拨', '撫': '抚', '撲': '扑', '撳': '揿', '撻': '挞', '撾': '挝',
'撿': '捡', '擁': '拥', '擄': '掳', '擇': '择', '擊': '击', '擋': '挡',
'擓': '㧟', '擔': '担', '據': '据', '擠': '挤', '擡': '抬', '擣': '捣',
'擬': '拟', '擯': '摈', '擰': '拧', '擱': '搁', '擲': '掷', '擴': '扩',
'擷': '撷', '擺': '摆', '擻': '擞', '擼': '撸', '擽': '㧰', '擾': '扰',
'攄': '摅', '攆': '撵', '攏': '拢', '攔': '拦', '攖': '撄', '攙': '搀',
'攛': '撺', '攜': '携', '攝': '摄', '攢': '攒', '攣': '挛', '攤': '摊',
'攪': '搅', '攬': '揽', '敎': '教', '敓': '敚', '敗': '败', '敘': '叙',
'敵': '敌', '數': '数', '斂': '敛', '斃': '毙', '斆': '敩', '斕': '斓',
'斬': '斩', '斷': '断', '於': '于', '旂': '旗', '旣': '既', '昇': '升',
'時': '时', '晉': '晋', '晛': "\u{2C02A}", '晝': '昼', '暈': '晕', '暉': '晖',
'暐': "\u{2C029}", '暘': '旸', '暢': '畅', '暫': '暂', '曄': '晔', '曆': '历',
'曇': '昙', '曉': '晓', '曏': '向', '曖': '暧', '曠': '旷', '曥': "\u{23190}",
'曨': '昽', '曬': '晒', '書': '书', '會': '会', '朥': "\u{266E8}", '朧': '胧',
'朮': '术', '東': '东', '枴': '拐', '柵': '栅', '柺': '拐', '査': '查',
'桱': "\u{23415}", '桿': '杆', '梔': '栀', '梘': '枧', '梜': "\u{2C0A9}", '條': '条',
'梟': '枭', '梲': '棁', '棄': '弃', '棊': '棋', '棖': '枨', '棗': '枣',
'棟': '栋', '棡': '㭎', '棧': '栈', '棲': '栖', '棶': '梾', '椏': '桠',
'椲': '㭏', '楊': '杨', '楓': '枫', '楨': '桢', '業': '业', '極': '极',
'榘': '矩', '榦': '干', '榪': '杩', '榮': '荣', '榲': '榅', '榿': '桤',
'構': '构', '槍': '枪', '槓': '杠', '槤': '梿', '槧': '椠', '槨': '椁',
'槮': '椮', '槳': '桨', '槶': '椢', '槼': '椝', '樁': '桩', '樂': '乐',
'樅': '枞', '樑': '梁', '樓': '楼', '標': '标', '樞': '枢', '樢': '㭤',
'樣': '样', '樧': '榝', '樫': '㭴', '樳': '桪', '樸': '朴', '樹': '树',
'樺': '桦', '樿': '椫', '橈': '桡', '橋': '桥', '機': '机', '橢': '椭',
'橫': '横', '橯': "\u{234FF}", '檁': '檩', '檉': '柽', '檔': '档', '檜': '桧',
'檟': '槚', '檢': '检', '檣': '樯', '檮': '梼', '檯': '台', '檳': '槟',
'檸': '柠', '檻': '槛', '櫃': '柜', '櫍': "\u{2C0CA}", '櫓': '橹', '櫚': '榈',
'櫛': '栉', '櫝': '椟', '櫞': '橼', '櫟': '栎', '櫥': '橱', '櫧': '槠',
'櫨': '栌', '櫪': '枥', '櫫': '橥', '櫬': '榇', '櫱': '蘖', '櫳': '栊',
'櫸': '榉', '櫻': '樱', '欄': '栏', '欅': '榉', '權': '权', '欏': '椤',
'欒': '栾', '欓': "\u{235CB}", '欖': '榄', '欞': '棂', '欽': '钦', '歎': '叹',
'歐': '欧', '歟': '欤', '歡': '欢', '歲': '岁', '歷': '历', '歸': '归',
'歿': '殁', '殘': '残', '殞': '殒', '殤': '殇', '殨': '㱮', '殫': '殚',
'殭': '僵', '殮': '殓', '殯': '殡', '殰': '㱩', '殲': '歼', '殺': '杀',
'殻': '壳', '殼': '壳', '毀': '毁', '毆': '殴', '毿': '毵', '氂': '牦',
'氈': '毡', '氌': '氇', '氣': '气', '氫': '氢', '氬': '氩', '氳': '氲',
'氾': '泛', '汎': '泛', '汙': '污', '決': '决', '沒': '没', '沖': '冲',
'況': '况', '泝': '溯', '洩': '泄', '洶': '汹', '浹': '浃', '浿': "\u{2C1D9}",
'涇': '泾', '涗': '涚', '涼': '凉', '淒': '凄', '淚': '泪', '淥': '渌',
'淨': '净', '淩': '凌', '淪': '沦', '淵': '渊', '淶': '涞', '淺': '浅',
'渙': '涣', '減': '减', '渢': '沨', '渦': '涡', '測': '测', '渾': '浑',
'湊': '凑', '湋': "\u{23C97}", '湞': '浈', '湧': '涌', '湯': '汤', '溈': '沩',
'準': '准', '溝': '沟', '溫': '温', '溮': '浉', '溳': '涢', '溼': '湿',
'滄': '沧', '滅': '灭', '滌': '涤', '滎': '荥', '滙': '汇', '滬': '沪',
'滯': '滞', '滲': '渗', '滷': '卤', '滸': '浒', '滻': '浐', '滾': '滚',
'滿': '满', '漁': '渔', '漊': '溇', '漍': "\u{2C1F9}", '漚': '沤', '漢': '汉',
'漣': '涟', '漬': '渍', '漲': '涨', '漵': '溆', '漸': '渐', '漿': '浆',
'潁': '颍', '潑': '泼', '潔': '洁', '潕': "\u{23C98}", '潙': '沩', '潚': '㴋',
'潛': '潜', '潤': '润', '潯': '浔', '潰': '溃', '潷': '滗', '潿': '涠',
'澀': '涩', '澆': '浇', '澇': '涝', '澐': '沄', '澗': '涧', '澠': '渑',
'澤': '泽', '澦': '滪', '澩': '泶', '澫': "\u{2C1D5}", '澮': '浍', '澱': '淀',
'澾': '㳠', '濁': '浊', '濃': '浓', '濄': '㳡', '濆': "\u{23E23}", '濕': '湿',
'濘': '泞', '濚': '溁', '濛': '蒙', '濜': '浕', '濟': '济', '濤': '涛',
'濧': '㳔', '濫': '滥', '濰': '潍', '濱': '滨', '濺': '溅', '濼': '泺',
'濾': '滤', '瀂': '澛', '瀅': '滢', '瀆': '渎', '瀇': '㲿', '瀉': '泻',
'瀋': '沈', '瀏': '浏', '瀕': '濒', '瀘': '泸', '瀝': '沥', '瀟': '潇',
'瀠': '潆', '瀦': '潴', '瀧': '泷', '瀨': '濑', '瀰': '弥', '瀲': '潋',
'瀾': '澜', '灃': '沣', '灄': '滠', '灑': '洒', '灒': "\u{2ADFD}", '灕': '漓',
'灘': '滩', '灙': "\u{23EBC}", '灝': '灏', '灡': '㳕', '灣': '湾', '灤': '滦',
'灧': '滟', '灩': '滟', '災': '灾', '為': '为', '烏': '乌', '烴': '烃',
'無': '无', '煉': '炼', '煒': '炜', '煙': '烟', '煢': '茕', '煥': '焕',
'煩': '烦', '煬': '炀', '煱': '㶽', '熅': '煴', '熒': '荧', '熗': '炝',
'熰': "\u{2C27C}", '熱': '热', '熲': '颎', '熾': '炽', '燀': "\u{2C2A4}", '燁': '烨',
'燈': '灯', '燉': '炖', '燒': '烧', '燖': "\u{2C288}", '燙': '烫', '燜': '焖',
'營': '营', '燦': '灿', '燬': '毁', '燭': '烛', '燴': '烩', '燶': '㶶',
'燻': '熏', '燼': '烬', '燾': '焘', '爍': '烁', '爐': '炉', '爛': '烂',
'爭': '争', '爲': '为', '爺': '爷', '爾': '尔', '牀': '床', '牆': '墙',
'牘': '牍', '牴': '抵', '牽': '牵', '犖': '荦', '犛': '牦', '犢': '犊',
'犧': '牺', '狀': '状', '狹': '狭', '狽': '狈', '猙': '狰', '猶': '犹',
'猻': '狲', '獁': '犸', '獃': '呆', '獄': '狱', '獅': '狮', '獎': '奖',
'獨': '独', '獪': '狯', '獫': '猃', '獮': '狝', '獰': '狞', '獱': '㺍',
'獲': '获', '獵': '猎', '獷': '犷', '獸': '兽', '獺': '獭', '獻': '献',
'獼': '猕', '玀': '猡', '現': '现', '琱': '雕', '琺': '珐', '琿': '珲',
'瑋': '玮', '瑒': '玚', '瑣': '琐', '瑤': '瑶', '瑩': '莹', '瑪': '玛',
'瑲': '玱', '璉': '琏', '璊': "\u{2B7A9}", '璕': "\u{2C364}", '璗': "\u{2C361}", '璡': '琎',
'璣': '玑', '璦': '瑷', '璫': '珰', '璯': '㻅', '環': '环', '璵': '玙',
'璸': '瑸', '璽': '玺', '璿': '璇', '瓅': "\u{2C35B}", '瓊': '琼', '瓏': '珑',
'瓔': '璎', '瓚': '瓒', '瓛': "\u{24A7D}", '甌': '瓯', '甕': '瓮', '產': '产',
'産': '产', '畝': '亩', '畢': '毕', '畫': '画', '異': '异', '畵': '画',
'當': '当', '疇': '畴', '疊': '叠', '痙': '痉', '痠': '酸', '痾': '疴',
'瘂': '痖', '瘋': '疯', '瘍': '疡', '瘓': '痪', '瘞': '瘗', '瘡': '疮',
'瘧': '疟', '瘮': '瘆', '瘲': '疭', '瘺': '瘘', '瘻': '瘘', '療': '疗',
'癆': '痨', '癇': '痫', '癉': '瘅', '癒': '愈', '癘': '疠', '癟': '瘪',
'癡': '痴', '癢': '痒', '癤': '疖', '癥': '症', '癧': '疬', '癩': '癞',
'癬': '癣', '癭': '瘿', '癮': '瘾', '癰': '痈', '癱': '瘫', '癲': '癫',
'發': '发', '皁': '皂', '皚': '皑', '皰': '疱', '皸': '皲', '皺': '皱',
'盃': '杯', '盜': '盗', '盞': '盏', '盡': '尽', '監': '监', '盤': '盘',
'盧': '卢', '盪': '荡', '眞': '真', '眥': '眦', '眾': '众', '睍': "\u{2AFA2}",
'睏': '困', '睜': '睁', '睞': '睐', '瞘': '眍', '瞜': '䁖', '瞞': '瞒',
'瞶': '瞆', '瞼': '睑', '矇': '蒙', '矓': '眬', '矚': '瞩', '矯': '矫',
'硃': '朱', '硜': '硁', '硤': '硖', '硨': '砗', '硯': '砚', '碕': '埼',
'碩': '硕', '碭': '砀', '碸': '砜', '確': '确', '碼': '码', '碽': '䂵',
'磑': '硙', '磚': '砖', '磠': '硵', '磣': '碜', '磧': '碛', '磯': '矶',
'磽': '硗', '磾': '䃅', '礄': '硚', '礎': '础', '礐': "\u{2C488}", '礙': '碍',
'礦': '矿', '礪': '砺', '礫': '砾', '礬': '矾', '礱': '砻', '祕': '秘',
'祿': '禄', '禍': '祸', '禎': '祯', '禕': '祎', '禡': '祃', '禦': '御',
'禪': '禅', '禮': '礼', '禰': '祢', '禱': '祷', '禿': '秃', '秈': '籼',
'稅': '税', '稈': '秆', '稏': '䅉', '稜': '棱', '稟': '禀', '種': '种',
'稱': '称', '穀': '谷', '穇': '䅟', '穌': '稣', '積': '积', '穎': '颖',
'穠': '秾', '穡': '穑', '穢': '秽', '穩': '稳', '穫': '获', '穭': '穞',
'窩': '窝', '窪': '洼', '窮': '穷', '窯': '窑', '窵': '窎', '窶': '窭',
'窺': '窥', '竄': '窜', '竅': '窍', '竇': '窦', '竈': '灶', '竊': '窃',
'竪': '竖', '競': '竞', '筆': '笔', '筍': '笋', '筧': '笕', '筴': '䇲',
'箇': '个', '箋': '笺', '箏': '筝', '箚': '札', '節': '节', '範': '范',
'築': '筑', '篋': '箧', '篔': '筼', '篠': '筿', '篢': "\u{2C542}", '篤': '笃',
'篩': '筛', '篳': '筚', '篸': "\u{25BBE}", '簀': '箦', '簍': '篓', '簑': '蓑',
'簞': '箪', '簡': '简', '簣': '篑', '簫': '箫', '簹': '筜', '簽': '签',
'簾': '帘', '籃': '篮', '籅': "\u{25AE3}", '籌': '筹', '籔': '䉤', '籙': '箓',
'籛': '篯', '籜': '箨', '籟': '籁', '籠': '笼', '籤': '签', '籩': '笾',
'籪': '簖', '籬': '篱', '籮': '箩', '籲': '吁', '粵': '粤', '糉': '粽',
'糝': '糁', '糞': '粪', '糧': '粮', '糰': '团', '糲': '粝', '糴': '籴',
'糶': '粜', '糹': '纟', '糾': '纠', '紀': '纪', '紂': '纣', '紃': "\u{2C613}",
'約': '约', '紅': '红', '紆': '纡', '紇': '纥', '紈': '纨', '紉': '纫',
'紋': '纹', '納': '纳', '紐': '纽', '紓': '纾', '純': '纯', '紕': '纰',
'紖': '纼', '紗': '纱', '紘': '纮', '紙': '纸', '級': '级', '紛': '纷',
'紜': '纭', '紝': '纴', '紞': "\u{2C618}", '紡': '纺', '紬': '䌷', '紮': '扎',
'細': '细', '紱': '绂', '紲': '绁', '紳': '绅', '紵': '纻', '紹': '绍',
'紺': '绀', '紼': '绋', '紿': '绐', '絀': '绌', '終': '终', '絃': '弦',
'組': '组', '絅': '䌹', '絆': '绊', '絎': '绗', '結': '结', '絕': '绝',
'絛': '绦', '絝': '绔', '絞': '绞', '絡': '络', '絢': '绚', '給': '给',
'絨': '绒', '絪': "\u{2C621}", '絰': '绖', '統': '统', '絲': '丝', '絳': '绛',
'絶': '绝', '絹': '绢', '絺': "\u{2B128}", '綁': '绑', '綃': '绡', '綄': "\u{2C62B}",
'綆': '绠', '綈': '绨', '綉': '绣', '綌': '绤', '綎': "\u{2C629}", '綏': '绥',
'綐': '䌼', '綑': '捆', '經': '经', '綖': "\u{2B127}", '綜': '综', '綝': "\u{2C62D}",
'綞': '缍', '綠': '绿', '綡': "\u{2B7C5}", '綢': '绸', '綣': '绻', '綧': "\u{2C62F}",
'綪': "\u{2C62C}", '綫': '线', '綬': '绶', '維': '维', '綯': '绹', '綰': '绾',
'綱': '纲', '網': '网', '綳': '绷', '綴': '缀', '綵': '彩', '綸': '纶',
'綹': '绺', '綺': '绮', '綻': '绽', '綽': '绰', '綾': '绫', '綿': '绵',
'緄': '绲', '緇': '缁', '緊': '紧', '緋': '绯', '緑': '绿', '緒': '绪',
'緓': '绬', '緔': '绱', '緗': '缃', '緘': '缄', '緙': '缂', '線': '线',
'緝': '缉', '緞': '缎', '締': '缔', '緡': '缗', '緣': '缘', '緦': '缌',
'編': '编', '緩': '缓', '緬': '缅', '緯': '纬', '緱': '缑', '緲': '缈',
'練': '练', '緶': '缏', '緹': '缇', '緻': '致', '緼': '缊', '縈': '萦',
'縉': '缙', '縊': '缢', '縋': '缒', '縐': '绉', '縑': '缣', '縕': '缊',
'縗': '缞', '縛': '缚', '縝': '缜', '縞': '缟', '縟': '缛', '縣': '县',
'縧': '绦', '縫': '缝', '縭': '缡', '縮': '缩', '縯': "\u{2C642}", '縱': '纵',
'縲': '缧', '縳': '䌸', '縴': '纤', '縵': '缦', '縶': '絷', '縷': '缕',
'縹': '缥', '總': '总', '績': '绩', '繃': '绷', '繅': '缫', '繆': '缪',
'繒': '缯', '織': '织', '繕': '缮', '繚': '缭', '繞': '绕', '繡': '绣',
'繢': '缋', '繩': '绳', '繪': '绘', '繫': '系', '繭': '茧', '繮': '缰',
'繯': '缳', '繰': '缲', '繳': '缴', '繶': "\u{2B137}", '繸': '䍁', '繹': '绎',
'繻': "\u{26221}", '繼': '继', '繽': '缤', '繾': '缱', '繿': '䍀', '纁': "\u{2B138}",
'纆': "\u{2C64A}", '纇': '颣', '纈': '缬', '纊': '纩', '續': '续', '纍': '累',
'纏': '缠', '纓': '缨', '纔': '才', '纕': "\u{2C64B}", '纖': '纤', '纘': '缵',
'纜': '缆', '缽': '钵', '罃': '䓨', '罈': '坛', '罌': '罂', '罎': '坛',
'罰': '罚', '罵': '骂', '罷': '罢', '羅': '罗', '羆': '罴', '羈': '羁',
'羋': '芈', '羣': '群', '羥': '羟', '羨': '羡', '義': '义', '羶': '膻',
'習': '习', '翫': '玩', '翬': '翚', '翹': '翘', '翽': '翙', '耬': '耧',
'耮': '耢', '聖': '圣', '聞': '闻', '聯': '联', '聰': '聪', '聲': '声',
'聳': '耸', '聵': '聩', '聶': '聂', '職': '职', '聹': '聍', '聽': '听',
'聾': '聋', '肅': '肃', '脅': '胁', '脈': '脉', '脛': '胫', '脣': '唇',
'脩': '修', '脫': '脱', '脹': '胀', '腎': '肾', '腖': '胨', '腡': '脶',
'腦': '脑', '腫': '肿', '腳': '脚', '腸': '肠', '膃': '腽', '膕': '腘',
'膚': '肤', '膞': '䏝', '膠': '胶', '膢': "\u{2677C}", '膩': '腻', '膽': '胆',
'膾': '脍', '膿': '脓', '臉': '脸', '臍': '脐', '臏': '膑', '臘': '腊',
'臚': '胪', '臟': '脏', '臠': '脔', '臢': '臜', '臥': '卧', '臨': '临',
'臺': '台', '與': '与', '興': '兴', '舉': '举', '舊': '旧', '舖': '铺',
'舘': '馆', '艙': '舱', '艤': '舣', '艦': '舰', '艫': '舻', '艱': '艰',
'艷': '艳', '芻': '刍', '苧': '苎', '茲': '兹', '荊': '荆', '莊': '庄',
'莖': '茎', '莢': '荚', '莧': '苋', '華': '华', '菴': '庵', '菸': '烟',
'萇': '苌', '萊': '莱', '萬': '万', '萴': '荝', '萵': '莴', '葉': '叶',
'葒': '荭', '葤': '荮', '葦': '苇', '葯': '药', '葷': '荤', '蒍': "\u{2B1ED}",
'蒐': '搜', '蒓': '莼', '蒔': '莳', '蒕': '蒀', '蒞': '莅', '蒼': '苍',
'蓀': '荪', '蓆': '席', '蓋': '盖', '蓮': '莲', '蓯': '苁', '蓴': '莼',
'蓽': '荜', '蔄': "\u{2C72C}", '蔔': '卜', '蔘': '参', '蔞': '蒌', '蔣': '蒋',
'蔥': '葱', '蔦': '茑', '蔭': '荫', '蔯': "\u{2B21F}", '蔿': "\u{2B1ED}", '蕁': '荨',
'蕆': '蒇', '蕎': '荞', '蕒': '荬', '蕓': '芸', '蕕': '莸', '蕘': '荛',
'蕢': '蒉', '蕩': '荡', '蕪': '芜', '蕭': '萧', '蕷': '蓣', '薀': '蕰',
'薈': '荟', '薊': '蓟', '薌': '芗', '薑': '姜', '薔': '蔷', '薘': '荙',
'薟': '莶', '薦': '荐', '薩': '萨', '薳': '䓕', '薴': '苧', '薵': '䓓',
'薹': '苔', '薺': '荠', '藍': '蓝', '藎': '荩', '藝': '艺', '藥': '药',
'藪': '薮', '藭': '䓖', '藴': '蕴', '藶': '苈', '藹': '蔼', '藺': '蔺',
'蘀': '萚', '蘄': '蕲', '蘆': '芦', '蘇': '苏', '蘊': '蕴', '蘋': '苹',
'蘚': '藓', '蘞': '蔹', '蘟': "\u{26ED5}", '蘢': '茏', '蘭': '兰', '蘺': '蓠',
'蘿': '萝', '虆': '蔂', '虉': "\u{2C7C1}", '處': '处', '虛': '虚', '虜': '虏',
'號': '号', '虧': '亏', '虯': '虬', '蛺': '蛱', '蛻': '蜕', '蜆': '蚬',
'蝀': "\u{2C7FD}", '蝕': '蚀', '蝟': '猬', '蝦': '虾', '蝨': '虱', '蝸': '蜗',
'螄': '蛳', '螞': '蚂', '螢': '萤', '螮': '䗖', '螻': '蝼', '螿': '螀',
'蟄': '蛰', '蟈': '蝈', '蟎': '螨', '蟣': '虮', '蟬': '蝉', '蟯': '蛲',
'蟲': '虫', '蟳': "\u{2B2BB}", '蟶': '蛏', '蟻': '蚁', '蠁': '蚃', '蠅': '蝇',
'蠆': '虿', '蠍': '蝎', '蠐': '蛴', '蠑': '蝾', '蠔': '蚝', '蠟': '蜡',
'蠣': '蛎', '蠨': '蟏', '蠱': '蛊', '蠶': '蚕', '蠻': '蛮', '衆': '众',
'衊': '蔑', '術': '术', '衕': '同', '衚': '胡', '衛': '卫', '衝': '冲',
'袞': '衮', '袷': '夹', '裊': '袅', '裏': '里', '補': '补', '裝': '装',
'裡': '里', '製': '制', '複': '复', '褌': '裈', '褘': '袆', '褲': '裤',
'褳': '裢', '褸': '褛', '褻': '亵', '襀': "\u{2B300}", '襇': '裥', '襉': '裥',
'襏': '袯', '襖': '袄', '襝': '裣', '襠': '裆', '襤': '褴', '襪': '袜',
'襬': '摆', '襯': '衬', '襲': '袭', '襴': '襕', '覈': '核', '見': '见',
'覎': '觃', '規': '规', '覓': '觅', '視': '视', '覘': '觇', '覡': '觋',
'覥': '觍', '覦': '觎', '親': '亲', '覬': '觊', '覯': '觏', '覲': '觐',
'覷': '觑', '覺': '觉', '覽': '览', '覿': '觌', '觀': '观', '觴': '觞',
'觶': '觯', '觸': '触', '訁': '讠', '訂': '订', '訃': '讣', '計': '计',
'訊': '讯', '訌': '讧', '討': '讨', '訏': "\u{2C8D9}", '訐': '讦', '訒': '讱',
'訓': '训', '訕': '讪', '訖': '讫', '託': '托', '記': '记', '訛': '讹',
'訝': '讶', '訟': '讼', '訢': '䜣', '訣': '诀', '訥': '讷', '訩': '讻',
'訪': '访', '設': '设', '許': '许', '訴': '诉', '訶': '诃', '診': '诊',
'註': '注', '証': '证', '詀': "\u{27BAA}", '詁': '诂', '詆': '诋', '詎': '讵',
'詐': '诈', '詒': '诒', '詔': '诏', '評': '评', '詖': '诐', '詗': '诇',
'詘': '诎', '詛': '诅', '詝': "\u{2C8DE}", '詞': '词', '詠': '咏', '詡': '诩',
'詢': '询', '詣': '诣', '試': '试', '詩': '诗', '詪': "\u{2C8F3}", '詫': '诧',
'詬': '诟', '詭': '诡', '詮': '诠', '詰': '诘', '話': '话', '該': '该',
'詳': '详', '詵': '诜', '詷': "\u{2B363}", '詼': '诙', '詿': '诖', '誄': '诔',
'誅': '诛', '誆': '诓', '誇': '夸', '誌': '志', '認': '认', '誑': '诳',
'誒': '诶', '誕': '诞', '誘': '诱', '誚': '诮', '語': '语', '誠': '诚',
'誡': '诫', '誣': '诬', '誤': '误', '誥': '诰', '誦': '诵', '誨': '诲',
'說': '说', '説': '说', '誰': '谁', '課': '课', '誶': '谇', '誹': '诽',
'誼': '谊', '誾': '訚', '調': '调', '諂': '谄', '諄': '谆', '談': '谈',
'諉': '诿', '請': '请', '諍': '诤', '諏': '诹', '諑': '诼', '諒': '谅',
'諓': "\u{2C8E1}", '論': '论', '諗': '谂', '諛': '谀', '諜': '谍', '諝': '谞',
'諞': '谝', '諟': "\u{2C90A}", '諡': '谥', '諢': '诨', '諤': '谔', '諦': '谛',
'諧': '谐', '諫': '谏', '諭': '谕', '諮': '咨', '諱': '讳', '諲': "\u{2C907}",
'諳': '谙', '諴': "\u{2B36F}", '諶': '谌', '諷': '讽', '諸': '诸', '諺': '谚',
'諼': '谖', '諾': '诺', '謀': '谋', '謁': '谒', '謂': '谓', '謄': '誊',
'謅': '诌', '謊': '谎', '謎': '谜', '謏': "\u{2B372}", '謐': '谧', '謔': '谑',
'謖': '谡', '謗': '谤', '謙': '谦', '謚': '谥', '講': '讲', '謝': '谢',
'謠': '谣', '謡': '谣', '謨': '谟', '謫': '谪', '謬': '谬', '謭': '谫',
'謳': '讴', '謹': '谨', '謾': '谩', '譁': '哗', '證': '证', '譎': '谲',
'譏': '讥', '譓': "\u{2C91D}", '譖': '谮', '識': '识', '譙': '谯', '譚': '谭',
'譜': '谱', '譞': "\u{2B37D}", '譟': '噪', '譫': '谵', '譭': '毁', '譯': '译',
'議': '议', '譴': '谴', '護': '护', '譸': '诪', '譽': '誉', '譾': '谫',
'讀': '读', '讅': '谉', '變': '变', '讋': '詟', '讌': '䜩', '讎': '雠',
'讒': '谗', '讓': '让', '讕': '谰', '讖': '谶', '讚': '赞', '讜': '谠',
'讞': '谳', '谿': '溪', '豈': '岂', '豎': '竖', '豐': '丰', '豔': '艳',
'豬': '猪', '豶': '豮', '貍': '狸', '貓': '猫', '貙': '䝙', '貝': '贝',
'貞': '贞', '貟': '贠', '負': '负', '財': '财', '貢': '贡', '貧': '贫',
'貨': '货', '販': '贩', '貪': '贪', '貫': '贯', '責': '责', '貯': '贮',
'貰': '贳', '貲': '赀', '貳': '贰', '貴': '贵', '貶': '贬', '買': '买',
'貸': '贷', '貺': '贶', '費': '费', '貼': '贴', '貽': '贻', '貿': '贸',
'賀': '贺', '賁': '贲', '賂': '赂', '賃': '赁', '賄': '贿', '賅': '赅',
'資': '资', '賈': '贾', '賊': '贼', '賑': '赈', '賒': '赊', '賓': '宾',
'賕': '赇', '賙': '赒', '賚': '赉', '賜': '赐', '賞': '赏', '賠': '赔',
'賡': '赓', '賢': '贤', '賣': '卖', '賤': '贱', '賦': '赋', '賧': '赕',
'質': '质', '賫': '赍', '賬': '账', '賭': '赌', '賰': '䞐', '賴': '赖',
'賵': '赗', '賺': '赚', '賻': '赙', '購': '购', '賽': '赛', '賾': '赜',
'贄': '贽', '贅': '赘', '贇': '赟', '贈': '赠', '贊': '赞', '贋': '赝',
'贍': '赡', '贏': '赢', '贐': '赆', '贓': '赃', '贔': '赑', '贖': '赎',
'贗': '赝', '贛': '赣', '贜': '赃', '赬': '赪', '趕': '赶', '趙': '赵',
'趨': '趋', '趲': '趱', '跡': '迹', '踐': '践', '踰': '逾', '踴': '踊',
'蹌': '跄', '蹕': '跸', '蹟': '迹', '蹠': '跖', '蹣': '蹒', '蹤': '踪',
'蹺': '跷', '躂': '跶', '躉': '趸', '躊': '踌', '躋': '跻', '躍': '跃',
'躎': '䟢', '躑': '踯', '躒': '跞', '躓': '踬', '躕': '蹰', '躚': '跹',
'躡': '蹑', '躥': '蹿', '躦': '躜', '躪': '躏', '軀': '躯', '車': '车',
'軋': '轧', '軌': '轨', '軍': '军', '軏': "\u{2B404}", '軑': '轪', '軒': '轩',
'軔': '轫', '軛': '轭', '軝': "\u{2CA02}", '軟': '软', '軤': '轷', '軫': '轸',
'軲': '轱', '軸': '轴', '軹': '轵', '軺': '轺', '軻': '轲', '軼': '轶',
'軾': '轼', '較': '较', '輄': "\u{28408}", '輅': '辂', '輇': '辁', '輈': '辀',
'載': '载', '輊': '轾', '輋': "\u{2AA36}", '輒': '辄', '輓': '挽', '輔': '辅',
'輕': '轻', '輗': "\u{2B410}", '輛': '辆', '輜': '辎', '輝': '辉', '輞': '辋',
'輟': '辍', '輥': '辊', '輦': '辇', '輩': '辈', '輪': '轮', '輬': '辌',
'輮': "\u{2B413}", '輯': '辑', '輳': '辏', '輶': "\u{2CA0E}", '輸': '输', '輻': '辐',
'輼': '辒', '輾': '辗', '輿': '舆', '轀': '辒', '轂': '毂', '轄': '辖',
'轅': '辕', '轆': '辘', '轉': '转', '轍': '辙', '轎': '轿', '轔': '辚',
'轟': '轰', '轡': '辔', '轢': '轹', '轤': '轳', '辦': '办', '辭': '辞',
'辮': '辫', '辯': '辩', '農': '农', '迴': '回', '逕': '径', '這': '这',
'連': '连', '週': '周', '進': '进', '遊': '游', '運': '运', '過': '过',
'達': '达', '違': '违', '遙': '遥', '遜': '逊', '遞': '递', '遠': '远',
'遡': '溯', '適': '适', '遲': '迟', '遶': '绕', '遷': '迁', '選': '选',
'遺': '遗', '遼': '辽', '邁': '迈', '還': '还', '邇': '迩', '邊': '边',
'邏': '逻', '邐': '逦', '郟': '郏', '郵': '邮', '鄆': '郓', '鄉': '乡',
'鄒': '邹', '鄔': '邬', '鄖': '郧', '鄧': '邓', '鄩': "\u{2CA7D}", '鄭': '郑',
'鄰': '邻', '鄲': '郸', '鄳': "\u{2B461}", '鄴': '邺', '鄶': '郐', '鄺': '邝',
'酇': '酂', '酈': '郦', '醃': '腌', '醖': '酝', '醜': '丑', '醞': '酝',
'醟': '蒏', '醣': '糖', '醫': '医', '醬': '酱', '醱': '酦', '醲': "\u{2CAA9}",
'釀': '酿', '釁': '衅', '釃': '酾', '釅': '酽', '釋': '释', '釐': '厘',
'釒': '钅', '釓': '钆', '釔': '钇', '釕': '钌', '釗': '钊', '釘': '钉',
'釙': '钋', '針': '针', '釣': '钓', '釤': '钐', '釦': '扣', '釧': '钏',
'釩': '钒', '釴': "\u{2CB29}", '釵': '钗', '釷': '钍', '釹': '钕', '釺': '钎',
'釾': '䥺', '釿': "\u{2CB31}", '鈀': '钯', '鈁': '钫', '鈃': '钘', '鈄': '钭',
'鈅': '钥', '鈇': "\u{2B4E7}", '鈈': '钚', '鈉': '钠', '鈍': '钝', '鈎': '钩',
'鈐': '钤', '鈑': '钣', '鈒': '钑', '鈔': '钞', '鈕': '钮', '鈞': '钧',
'鈡': '钟', '鈣': '钙', '鈥': '钬', '鈦': '钛', '鈧': '钪', '鈮': '铌',
'鈰': '铈', '鈳': '钶', '鈴': '铃', '鈷': '钴', '鈸': '钹', '鈹': '铍',
'鈺': '钰', '鈽': '钸', '鈾': '铀', '鈿': '钿', '鉀': '钾', '鉅': '巨',
'鉆': '钻', '鉈': '铊', '鉉': '铉', '鉊': "\u{2CB3F}", '鉋': '铇', '鉍': '铋',
'鉑': '铂', '鉕': '钷', '鉗': '钳', '鉚': '铆', '鉛': '铅', '鉝': "\u{2B7F7}",
'鉞': '钺', '鉢': '钵', '鉤': '钩', '鉥': "\u{2CB38}", '鉦': '钲', '鉧': "\u{2CB41}",
'鉬': '钼', '鉭': '钽', '鉮': "\u{2CB39}", '鉳': '锫', '鉶': '铏', '鉷': "\u{2B7F9}",
'鉸': '铰', '鉺': '铒', '鉻': '铬', '鉿': '铪', '銀': '银', '銃': '铳',
'銅': '铜', '銈': "\u{2B4EF}", '銍': '铚', '銑': '铣', '銓': '铨', '銖': '铢',
'銘': '铭', '銚': '铫', '銛': '铦', '銜': '衔', '銠': '铑', '銣': '铷',
'銥': '铱', '銦': '铟', '銨': '铵', '銩': '铥', '銪': '铕', '銫': '铯',
'銬': '铐', '銱': '铞', '銳': '锐', '銶': "\u{28C47}", '銷': '销', '銹': '锈',
'銻': '锑', '銼': '锉', '鋁': '铝', '鋃': '锒', '鋅': '锌', '鋇': '钡',
'鋌': '铤', '鋏': '铗', '鋐': "\u{2CB4E}", '鋒': '锋', '鋗': "\u{2B4F6}", '鋙': '铻',
'鋝': '锊', '鋟': '锓', '鋣': '铘', '鋤': '锄', '鋥': '锃', '鋦': '锔',
'鋨': '锇', '鋩': '铓', '鋪': '铺', '鋭': '锐', '鋮': '铖', '鋯': '锆',
'鋰': '锂', '鋱': '铽', '鋶': '锍', '鋸': '锯', '鋹': "\u{2CB2E}", '鋼': '钢',
'錀': "\u{2CB2D}", '錁': '锞', '錄': '录', '錆': '锖', '錇': '锫', '錈': '锩',
'錏': '铔', '錐': '锥', '錒': '锕', '錕': '锟', '錘': '锤', '錙': '锱',
'錚': '铮', '錛': '锛', '錞': "\u{2CB5A}", '錟': '锬', '錠': '锭', '錡': '锜',
'錢': '钱', '錤': "\u{2B4F9}", '錦': '锦', '錨': '锚', '錩': '锠', '錫': '锡',
'錮': '锢', '錯': '错', '録': '录', '錳': '锰', '錶': '表', '錸': '铼',
'錼': '镎', '鍀': '锝', '鍁': '锨', '鍃': '锪', '鍅': '钫', '鍆': '钔',
'鍇': '锴', '鍈': '锳', '鍊': '炼', '鍋': '锅', '鍍': '镀', '鍔': '锷',
'鍘': '铡', '鍚': '钖', '鍛': '锻', '鍠': '锽', '鍤': '锸', '鍥': '锲',
'鍩': '锘', '鍬': '锹', '鍭': "\u{2CB64}", '鍰': '锾', '鍵': '键', '鍶': '锶',
'鍺': '锗', '鍼': '针', '鍾': '钟', '鎂': '镁', '鎄': '锿', '鎇': '镅',
'鎊': '镑', '鎌': '镰', '鎓': "\u{2CB69}", '鎔': '镕', '鎖': '锁', '鎘': '镉',
'鎚': '锤', '鎛': '镈', '鎝': "\u{28C4F}", '鎡': '镃', '鎢': '钨', '鎣': '蓥',
'鎦': '镏', '鎧': '铠', '鎩': '铩', '鎪': '锼', '鎬': '镐', '鎭': '镇',
'鎮': '镇', '鎰': '镒', '鎲': '镋', '鎳': '镍', '鎵': '镓', '鎶': '鿔',
'鎸': '镌', '鎿': '镎', '鏃': '镞', '鏇': '旋', '鏈': '链', '鏌': '镆',
'鏍': '镙', '鏏': "\u{2CB6C}", '鏐': '镠', '鏑': '镝', '鏗': '铿', '鏘': '锵',
'鏜': '镗', '鏝': '镘', '鏞': '镛', '鏟': '铲', '鏡': '镜', '鏢': '镖',
'鏤': '镂', '鏨': '錾', '鏰': '镚', '鏵': '铧', '鏷': '镤', '鏹': '镪',
'鏺': '䥽', '鏻': "\u{2CB78}", '鏽': '锈', '鐃': '铙', '鐄': "\u{28C51}", '鐇': "\u{2B50D}",
'鐋': '铴', '鐍': "\u{2B50E}", '鐏': "\u{28C54}", '鐐': '镣', '鐒': '铹', '鐓': '镦',
'鐔': '镡', '鐘': '钟', '鐙': '镫', '鐝': '镢', '鐠': '镨', '鐥': '䦅',
'鐦': '锎', '鐧': '锏', '鐨': '镄', '鐩': "\u{2CB7C}", '鐫': '镌', '鐮': '镰',
'鐯': '䦃', '鐲': '镯', '鐳': '镭', '鐵': '铁', '鐶': '镮', '鐸': '铎',
'鐺': '铛', '鐽': "\u{2B7FC}", '鐿': '镱', '鑄': '铸', '鑊': '镬', '鑌': '镔',
'鑑': '鉴', '鑒': '鉴', '鑔': '镲', '鑕': '锧', '鑞': '镴', '鑠': '铄',
'鑣': '镳', '鑥': '镥', '鑪': "\u{2CB3B}", '鑭': '镧', '鑰': '钥', '鑱': '镵',
'鑲': '镶', '鑷': '镊', '鑹': '镩', '鑼': '锣', '鑽': '钻', '鑾': '銮',
'鑿': '凿', '钁': '镢', '钂': '镋', '長': '长', '門': '门', '閂': '闩',
'閃': '闪', '閆': '闫', '閈': '闬', '閉': '闭', '開': '开', '閌': '闶',
'閎': '闳', '閏': '闰', '閑': '闲', '閒': '闲', '間': '间', '閔': '闵',
'閘': '闸', '閡': '阂', '閣': '阁', '閤': '合', '閥': '阀', '閨': '闺',
'閩': '闽', '閫': '阃', '閬': '阆', '閭': '闾', '閱': '阅', '閲': '阅',
'閶': '阊', '閹': '阉', '閻': '阎', '閼': '阏', '閽': '阍', '閾': '阈',
'閿': '阌', '闃': '阒', '闆': '板', '闇': '暗', '闈': '闱', '闉': "\u{2CBB1}",
'闊': '阔', '闋': '阕', '闌': '阑', '闍': '阇', '闐': '阗', '闑': "\u{2B536}",
'闒': '阘', '闓': '闿', '闔': '阖', '闕': '阙', '闖': '闯', '關': '关',
'闞': '阚', '闠': '阓', '闡': '阐', '闢': '辟', '闤': '阛', '闥': '闼',
'陘': '陉', '陝': '陕', '陞': '升', '陣': '阵', '陰': '阴', '陳': '陈',
'陸': '陆', '陽': '阳', '隉': '陧', '隊': '队', '階': '阶', '隑': "\u{2CBBF}",
'隕': '陨', '際': '际', '隤': "\u{2CBCE}", '隨': '随', '險': '险', '隮': "\u{2CBC0}",
'隯': '陦', '隱': '隐', '隴': '陇', '隸': '隶', '隻': '只', '雋': '隽',
'雖': '虽', '雙': '双', '雛': '雏', '雜': '杂', '雞': '鸡', '離': '离',
'難': '难', '雲': '云', '電': '电', '霑': '沾', '霢': '霡', '霧': '雾',
'霽': '霁', '靂': '雳', '靄': '霭', '靆': '叇', '靈': '灵', '靉': '叆',
'靚': '靓', '靜': '静', '靝': '靔', '靦': '腼', '靨': '靥', '鞏': '巩',
'鞝': '绱', '鞦': '秋', '鞽': '鞒', '韁': '缰', '韃': '鞑', '韆': '千',
'韉': '鞯', '韋': '韦', '韌': '韧', '韍': '韨', '韓': '韩', '韙': '韪',
'韜': '韬', '韝': '鞲', '韞': '韫', '韻': '韵', '響': '响', '頁': '页',
'頂': '顶', '頃': '顷', '項': '项', '順': '顺', '頇': '顸', '須': '须',
'頊': '顼', '頌': '颂', '頍': "\u{2B806}", '頎': '颀', '頏': '颃', '預': '预',
'頑': '顽', '頒': '颁', '頓': '顿', '頔': "\u{2CC56}", '頗': '颇', '領': '领',
'頜': '颌', '頠': "\u{2CC5F}", '頡': '颉', '頤': '颐', '頦': '颏', '頫': "\u{2B5AF}",
'頭': '头', '頮': '颒', '頰': '颊', '頲': '颋', '頴': '颕', '頵': "\u{2B5B3}",
'頷': '颔', '頸': '颈', '頹': '颓', '頻': '频', '頽': '颓', '顆': '颗',
'題': '题', '額': '额', '顎': '颚', '顏': '颜', '顒': '颙', '顓': '颛',
'顔': '颜', '顗': "\u{2B5AE}", '願': '愿', '顙': '颡', '顛': '颠', '類': '类',
'顢': '颟', '顥': '颢', '顧': '顾', '顫': '颤', '顬': '颥', '顯': '显',
'顰': '颦', '顱': '颅', '顳': '颞', '顴': '颧', '風': '风', '颭': '飐',
'颮': '飑', '颯': '飒', '颱': '台', '颳': '刮', '颶': '飓', '颸': '飔',
'颺': '飏', '颻': '飖', '颼': '飕', '飀': '飗', '飄': '飘', '飆': '飙',
'飈': '飚', '飛': '飞', '飠': '饣', '飢': '饥', '飣': '饤', '飥': '饦',
'飩': '饨', '飪': '饪', '飫': '饫', '飭': '饬', '飯': '饭', '飱': '飧',
'飲': '饮', '飴': '饴', '飼': '饲', '飽': '饱', '飾': '饰', '飿': '饳',
'餃': '饺', '餄': '饸', '餅': '饼', '餈': '糍', '餉': '饷', '養': '养',
'餌': '饵', '餎': '饹', '餏': '饻', '餑': '饽', '餒': '馁', '餓': '饿',
'餕': '馂', '餖': '饾', '餗': "\u{2B5E7}", '餘': '余', '餚': '肴', '餛': '馄',
'餜': '馃', '餞': '饯', '餡': '馅', '館': '馆', '餬': '糊', '餱': '糇',
'餳': '饧', '餵': '喂', '餶': '馉', '餷': '馇', '餸': "\u{2980C}", '餺': '馎',
'餼': '饩', '餾': '馏', '餿': '馊', '饁': '馌', '饃': '馍', '饅': '馒',
'饈': '馐', '饉': '馑', '饊': '馓', '饋': '馈', '饌': '馔', '饑': '饥',
'饒': '饶', '饗': '飨', '饘': "\u{2B5F4}", '饜': '餍', '饞': '馋', '饢': '馕',
'馬': '马', '馭': '驭', '馮': '冯', '馱': '驮', '馳': '驰', '馴': '驯',
'馹': '驲', '馼': "\u{2B61C}", '駁': '驳', '駃': "\u{2B61D}", '駉': "\u{2CCF6}", '駐': '驻',
'駑': '驽', '駒': '驹', '駓': "\u{2CCF5}", '駔': '驵', '駕': '驾', '駘': '骀',
'駙': '驸', '駛': '驶', '駝': '驼', '駟': '驷', '駡': '骂', '駢': '骈',
'駪': "\u{2CCFD}", '駭': '骇', '駰': '骃', '駱': '骆', '駸': '骎', '駼': "\u{2CCFF}",
'駿': '骏', '騁': '骋', '騂': '骍', '騄': "\u{2B627}", '騅': '骓', '騊': "\u{2B626}",
'騌': '骔', '騍': '骒', '騎': '骑', '騏': '骐', '騑': "\u{2CD02}", '騖': '骛',
'騙': '骗', '騞': "\u{2CD03}", '騠': "\u{2B628}", '騤': '骙', '騧': '䯄', '騫': '骞',
'騭': '骘', '騮': '骝', '騰': '腾', '騱': "\u{2B62C}", '騵': "\u{2B62A}", '騶': '驺',
'騷': '骚', '騸': '骟', '騾': '骡', '驀': '蓦', '驁': '骜', '驂': '骖',
'驃': '骠', '驄': '骢', '驅': '驱', '驊': '骅', '驌': '骕', '驍': '骁',
'驎': "\u{2CD0A}", '驏': '骣', '驕': '骄', '驗': '验', '驚': '惊', '驛': '驿',
'驟': '骤', '驢': '驴', '驤': '骧', '驥': '骥', '驦': '骦', '驪': '骊',
'驫': '骉', '骯': '肮', '髏': '髅', '髒': '脏', '體': '体', '髕': '髌',
'髖': '髋', '髮': '发', '鬆': '松', '鬍': '胡', '鬚': '须', '鬢': '鬓',
'鬥': '斗', '鬧': '闹', '鬨': '哄', '鬩': '阋', '鬮': '阄', '鬱': '郁',
'鬹': '鬶', '魎': '魉', '魘': '魇', '魚': '鱼', '魛': '鱽', '魟': "\u{2B689}",
'魢': '鱾', '魨': '鲀', '魯': '鲁', '魴': '鲂', '魷': '鱿', '魺': '鲄',
'鮀': "\u{2CD8D}", '鮁': '鲅', '鮃': '鲆', '鮆': "\u{2B696}", '鮈': "\u{2CD8B}", '鮊': '鲌',
'鮋': '鲉', '鮍': '鲏', '鮎': '鲇', '鮐': '鲐', '鮑': '鲍', '鮒': '鲋',
'鮓': '鲊', '鮚': '鲒', '鮜': '鲘', '鮝': '鲞', '鮞': '鲕', '鮟': "\u{29F7E}",
'鮠': "\u{2CD8F}", '鮡': "\u{2CD90}", '鮣': '䲟', '鮦': '鲖', '鮪': '鲔', '鮫': '鲛',
'鮭': '鲑', '鮮': '鲜', '鮳': '鲓', '鮶': '鲪', '鮸': "\u{29F83}", '鮺': '鲝',
'鯀': '鲧', '鯁': '鲠', '鯇': '鲩', '鯉': '鲤', '鯊': '鲨', '鯒': '鲬',
'鯔': '鲻', '鯕': '鲯', '鯖': '鲭', '鯗': '鲞', '鯛': '鲷', '鯝': '鲴',
'鯡': '鲱', '鯢': '鲵', '鯤': '鲲', '鯧': '鲳', '鯨': '鲸', '鯪': '鲮',
'鯫': '鲰', '鯰': '鲶', '鯴': '鲺', '鯷': '鳀', '鯻': "\u{2CD9F}", '鯽': '鲫',
'鯿': '鳊', '鰁': '鳈', '鰂': '鲗', '鰃': '鳂', '鰆': '䲠', '鰈': '鲽',
'鰉': '鳇', '鰊': "\u{2CDA0}", '鰌': '䲡', '鰍': '鳅', '鰏': '鲾', '鰐': '鳄',
'鰒': '鳆', '鰓': '鳃', '鰛': '鳁', '鰜': '鳒', '鰟': '鳑', '鰠': '鳋',
'鰣': '鲥', '鰤': "\u{2B695}", '鰥': '鳏', '鰧': '䲢', '鰨': '鳎', '鰩': '鳐',
'鰭': '鳍', '鰮': '鳁', '鰱': '鲢', '鰲': '鳌', '鰳': '鳓', '鰵': '鳘',
'鰶': "\u{2CDAD}", '鰷': '鲦', '鰹': '鲣', '鰺': '鲹', '鰻': '鳗', '鰼': '鳛',
'鰾': '鳔', '鱀': "\u{2CDA8}", '鱂': '鳉', '鱅': '鳙', '鱇': "\u{29F8C}", '鱈': '鳕',
'鱉': '鳖', '鱒': '鳟', '鱔': '鳝', '鱖': '鳜', '鱗': '鳞', '鱘': '鲟',
'鱚': "\u{2CDAE}", '鱝': '鲼', '鱟': '鲎', '鱠': '鲙', '鱣': '鳣', '鱤': '鳡',
'鱧': '鳢', '鱨': '鲿', '鱭': '鲚', '鱯': '鳠', '鱲': "\u{2B6AD}", '鱷': '鳄',
'鱸': '鲈', '鱺': '鲡', '鳥': '鸟', '鳧': '凫', '鳩': '鸠', '鳬': '凫',
'鳲': '鸤', '鳳': '凤', '鳴': '鸣', '鳶': '鸢', '鳾': '䴓', '鴆': '鸩',
'鴇': '鸨', '鴉': '鸦', '鴒': '鸰', '鴕': '鸵', '鴛': '鸳', '鴝': '鸲',
'鴞': '鸮', '鴟': '鸱', '鴣': '鸪', '鴦': '鸯', '鴨': '鸭', '鴯': '鸸',
'鴰': '鸹', '鴴': '鸻', '鴷': '䴕', '鴻': '鸿', '鴿': '鸽', '鵁': '䴔',
'鵂': '鸺', '鵃': '鸼', '鵏': "\u{2CDD5}", '鵐': '鹀', '鵑': '鹃', '鵒': '鹆',
'鵓': '鹁', '鵜': '鹈', '鵝': '鹅', '鵟': "\u{2B6ED}", '鵠': '鹄', '鵡': '鹉',
'鵪': '鹌', '鵬': '鹏', '鵮': '鹐', '鵯': '鹎', '鵰': '雕', '鵲': '鹊',
'鵷': '鹓', '鵾': '鹍', '鶄': '䴖', '鶇': '鸫', '鶉': '鹑', '鶊': '鹒',
'鶓': '鹋', '鶖': '鹙', '鶘': '鹕', '鶚': '鹗', '鶠': "\u{2CE18}", '鶡': '鹖',
'鶥': '鹛', '鶩': '鹜', '鶪': '䴗', '鶬': '鸧', '鶯': '莺', '鶱': "\u{2CE23}",
'鶲': '鹟', '鶴': '鹤', '鶹': '鹠', '鶺': '鹡', '鶻': '鹘', '鶼': '鹣',
'鶿': '鹚', '鷀': '鹚', '鷁': '鹢', '鷂': '鹞', '鷄': '鸡', '鷉': '䴘',
'鷊': '鹝', '鷓': '鹧', '鷖': '鹥', '鷗': '鸥', '鷙': '鸷', '鷚': '鹨',
'鷟': "\u{2CE26}", '鷥': '鸶', '鷦': '鹪', '鷫': '鹔', '鷭': "\u{2CE2A}", '鷯': '鹩',
'鷲': '鹫', '鷳': '鹇', '鷴': '鹇', '鷸': '鹬', '鷹': '鹰', '鷺': '鹭',
'鷽': '鸴', '鸂': '㶉', '鸇': '鹯', '鸊': '䴙', '鸌': '鹱', '鸏': '鹲',
'鸑': "\u{2CE1A}", '鸕': '鸬', '鸘': '鹴', '鸚': '鹦', '鸛': '鹳', '鸝': '鹂',
'鸞': '鸾', '鹵': '卤', '鹹': '咸', '鹺': '鹾', '鹼': '碱', '鹽': '盐',
'麗': '丽', '麥': '麦', '麩': '麸', '麪': '面', '麫': '面', '麬': "\u{24FF2}",
'麯': '曲', '麳': "\u{2A38C}", '麴': '曲', '麵': '面', '麼': '么', '麽': '么',
'黃': '黄', '黌': '黉', '點': '点', '黨': '党', '黲': '黪', '黴': '霉',
'黶': '黡', '黷': '黩', '黽': '黾', '黿': '鼋', '鼂': '鼌', '鼉': '鼍',
'鼕': '冬', '鼴': '鼹', '齊': '齐', '齋': '斋', '齎': '赍', '齏': '齑',
'齒': '齿', '齔': '龀', '齕': '龁', '齗': '龂', '齘': "\u{2CE7C}", '齙': '龅',
'齜': '龇', '齟': '龃', '齠': '龆', '齡': '龄', '齣': '出', '齦': '龈',
'齧': '啮', '齪': '龊', '齬': '龉', '齮': "\u{2CE88}", '齯': "\u{2B81C}", '齲': '龋',
'齶': '腭', '齷': '龌', '齼': "\u{2CE93}", '龍': '龙', '龎': '厐', '龐': '庞',
'龑': '䶮', '龔': '龚', '龕': '龛', '龜': '龟', '鿁': '䜤', '鿓': '鿒',
"\u{2005E}": "\u{2003E}", "\u{205E3}": '㓆', "\u{210D5}": "\u{20D1B}", "\u{2114F}": "\u{20CA5}", "\u{2144D}": "\u{2BB7C}", "\u{2146D}": "\u{212D7}",
"\u{214FE}": "\u{212C0}", "\u{21516}": "\u{21363}", "\u{217B5}": '㛟', "\u{21839}": '㛿', "\u{21883}": '㛠', "\u{21B89}": "\u{21B5C}",
"\u{21BA3}": "\u{21B6C}", "\u{21CF3}": "\u{21CC3}", "\u{21ED5}": '岁', "\u{21FB1}": '㟜', "\u{228DA}": "\u{2261D}", "\u{22DAB}": "\u{22ADE}",
"\u{22E7F}": "\u{22B26}", "\u{23236}": '暅', "\u{2364E}": '㭣', "\u{237BB}": "\u{23613}", "\u{23829}": "\u{2378E}", "\u{23832}": "\u{23476}",
"\u{23BF6}": '毶', "\u{23FB7}": '㳢', "\u{24063}": "\u{23EBD}", "\u{24176}": "\u{23DF7}", "\u{244E9}": "\u{242B0}", "\u{24ABA}": '㻘',
"\u{24AE9}": '㻏', "\u{24CF8}": "\u{24CC4}", "\u{2529D}": "\u{2517F}", "\u{25303}": "\u{25158}", "\u{25565}": "\u{25430}", "\u{25585}": "\u{2542F}",
"\u{255FD}": "\u{2C497}", "\u{258A2}": '䅪', "\u{25E20}": "\u{25B8B}", "\u{25F3D}": "\u{25E65}", "\u{26627}": "\u{21CD2}", "\u{268CE}": "\u{267D7}",
"\u{26A99}": '䑽', "\u{27717}": '䘞', "\u{27735}": '䙊', "\u{2775E}": '䘛', "\u{277C0}": "\u{27767}", "\u{27A59}": '䜥',
"\u{27D73}": '䞌', "\u{27DA7}": '䞎', "\u{282B0}": '䢀', "\u{282B8}": '䢁', "\u{282E2}": '䢂', "\u{2893B}": "\u{28930}",
"\u{289AB}": '䦀', "\u{289C0}": "\u{2CB4A}", "\u{289DC}": '䦁', "\u{28A0F}": "\u{2CB5B}", "\u{28B46}": "\u{2CB76}", "\u{28B4E}": "\u{2CB73}",
"\u{28BC5}": '䥿', "\u{297AF}": '䭪', "\u{29834}": "\u{29820}", "\u{298D1}": '䯃', "\u{29D98}": '䲞', "\u{30EDE}": "\u{30EDD}",
}
+17
View File
@@ -0,0 +1,17 @@
export function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | undefined;
return function (this: any, ...args: Parameters<T>) {
const context = this;
const later = () => {
timeout = undefined;
func.apply(context, args);
};
if (timeout !== undefined) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}
+14
View File
@@ -0,0 +1,14 @@
// 微信 / QQ 内置浏览器 UA 检测(仅客户端可用,SSR 环境返回全 false)
// 原理:微信内置 WebView(含 PC 版)UA 携带 MicroMessenger;
// QQ 内置 WebView 携带独立的 " QQ/版本号" 段或老安卓的 V1_AND_SQ_,
// 独立 QQ 浏览器只有 MQQBrowser 而无独立的 " QQ/" 段,因此会被天然排除。
export const detectEmbeddedBrowser = (userAgent?: string): {
isWeChat: boolean
isQQApp: boolean
} => {
const ua = userAgent || (typeof window !== 'undefined' ? window.navigator.userAgent : '')
const isWeChat = /MicroMessenger/i.test(ua)
// 仅匹配独立的 " QQ/" 段:QQ 浏览器的 MQQBrowser/QQ/8.x 形式不会触发误判
const isQQApp = /\sQQ\/[\d.]/i.test(ua) || /V1_AND_SQ/i.test(ua)
return { isWeChat, isQQApp }
}
+32
View File
@@ -0,0 +1,32 @@
// 解析班级批量输入:逗号/换行分隔;数字区间 "1-10" 生成 "1班".."10班"(上限 100 防呆)
const MAX_RANGE_LENGTH = 100
export const parseClassInput = (text: unknown): string[] => {
if (typeof text !== 'string') return []
const segments = text
.split(/[,,\n;;]/)
.map((segment) => segment.trim())
.filter(Boolean)
const result: string[] = []
for (const segment of segments) {
const rangeMatch = segment.match(/^(\d+)-(\d+)$/)
if (rangeMatch) {
const start = parseInt(rangeMatch[1], 10)
const end = parseInt(rangeMatch[2], 10)
if (start >= 1 && end >= start && end - start < MAX_RANGE_LENGTH) {
for (let i = start; i <= end; i++) {
result.push(`${i}班`)
}
continue
}
}
result.push(segment)
}
return [...new Set(result)]
}
+26
View File
@@ -0,0 +1,26 @@
// 学段权重权威定义(与 server/utils/grade-class-core.ts 保持一致;前端唯一引用源)
export const GRADE_ORDER = {
初一: 1,
初二: 2,
初三: 3,
高一: 4,
高二: 5,
高三: 6,
大一: 7,
大二: 8,
大三: 9,
大四: 10,
教师: 99,
教职工: 99
}
export const smartSort = (a, b) => {
const weightA = GRADE_ORDER[a]
const weightB = GRADE_ORDER[b]
if (weightA !== undefined && weightB !== undefined) return weightA - weightB
if (weightA !== undefined) return -1
if (weightB !== undefined) return 1
return a.localeCompare(b, 'zh-CN', { numeric: true })
}
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
import { computed, ref } from 'vue'
import * as zhCN from './zh-CN'
export const supportedLocales = [
{ code: 'zh-CN', label: '简体中文' },
{ code: 'en-US', label: 'English' }
] as const
export type Locale = (typeof supportedLocales)[number]['code']
type LocaleValue<T> = T extends (...args: infer Args) => infer Return
? (...args: Args) => Return
: T extends readonly (infer Item)[]
? readonly LocaleValue<Item>[]
: T extends string
? string
: T extends object
? { [Key in keyof T]: LocaleValue<T[Key]> }
: T
export type LocaleMessages = LocaleValue<typeof zhCN>
type LocaleSectionKey = keyof LocaleMessages
type LegacyNestedSectionKey = 'auth' | 'ui' | 'songs'
const LOCALE_STORAGE_KEY = 'voicehub.locale'
export const LOCALE_COOKIE_KEY = 'voicehub.locale'
// 语言偏好来源:'manual' = 用户手动选择(长期记住),'system' = 自动跟随系统(每次进入重新解析)
export const LOCALE_PREFERENCE_KEY = 'voicehub.locale.pref'
export type LocalePreference = 'manual' | 'system'
export const FALLBACK_LOCALE: Locale = 'zh-CN'
// 语言 cookie 共用配置(长期有效,跟随手动/系统模式一起写入)
const localeCookieOptions = {
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax' as const,
path: '/'
}
export const isSupportedLocale = (locale: string | null | undefined): locale is Locale =>
supportedLocales.some((item) => item.code === locale)
// 词典按需加载:中文作为兜底与合并基底静态内置,其余语言在被激活时才动态加载,
// 避免默认语言用户下载不需要的语言包。
const zhMessages = zhCN as unknown as LocaleMessages
const localeLoaders: Record<Locale, () => Promise<LocaleMessages>> = {
'zh-CN': () => Promise.resolve(zhMessages),
'en-US': () => import('./en-US').then((module) => module as unknown as LocaleMessages)
}
// 已加载词典缓存。词典内容不可变,可在服务端跨请求安全共享(只做追加)。
const loadedMessages = ref<Partial<Record<Locale, LocaleMessages>>>({
'zh-CN': zhMessages
})
const loadingPromises: Partial<Record<Locale, Promise<LocaleMessages>>> = {}
export function isLocaleMessagesLoaded(locale: Locale): boolean {
return Boolean(loadedMessages.value[locale])
}
export async function loadLocaleMessages(locale: Locale): Promise<void> {
if (!isSupportedLocale(locale) || loadedMessages.value[locale]) return
if (!loadingPromises[locale]) {
loadingPromises[locale] = localeLoaders[locale]().then((module) => {
loadedMessages.value = { ...loadedMessages.value, [locale]: module }
return module
})
}
await loadingPromises[locale]
}
// 客户端语言解析:用户手动偏好(localStorage)→ 浏览器/系统语言 → 兜底。
// 自动跟随系统的解析结果不写入 localStorage,系统语言变化后才能重新跟随。
export function resolveClientInitialLocale(): Locale {
if (!import.meta.client) return FALLBACK_LOCALE
const savedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY)
const preference = window.localStorage.getItem(LOCALE_PREFERENCE_KEY)
if (preference === 'manual' && isSupportedLocale(savedLocale)) return savedLocale
// navigator.languages 为浏览器首选语言列表(通常跟随系统语言),逐项匹配更准确
const browserLanguages =
window.navigator.languages?.length > 0 ? window.navigator.languages : [window.navigator.language]
for (const language of browserLanguages) {
if (!language) continue
if (isSupportedLocale(language)) return language
const normalized = language.toLowerCase()
if (normalized.startsWith('zh')) return 'zh-CN'
if (normalized.startsWith('en')) return 'en-US'
}
return FALLBACK_LOCALE
}
// 服务端语言解析:根据 Accept-Language 头选择最合适的受支持语言。
export function resolveLocaleFromAcceptLanguage(header?: string | null): Locale {
if (!header) return FALLBACK_LOCALE
// 解析 (语言, q 权重) 对,缺省 q=1,按 q 稳定降序后再匹配;
// 否则会忽略权重,对 `zh;q=0.1,en;q=0.9` 这类头选错语言。
const languages = header
.split(',')
.map((part) => {
const [lang, ...params] = part.trim().split(';')
const qParam = params.find((p) => p.trim().startsWith('q='))
const q = qParam ? Number(qParam.split('=')[1]) : 1
return { lang: lang?.trim().toLowerCase() ?? '', q: Number.isFinite(q) ? q : 0 }
})
.filter((item) => item.lang)
.sort((a, b) => b.q - a.q)
for (const { lang } of languages) {
if (lang.startsWith('zh')) return 'zh-CN'
if (lang.startsWith('en')) return 'en-US'
}
return FALLBACK_LOCALE
}
// 当前语言状态:
// - 在 Nuxt 上下文内用 useState:SSR 下每个请求相互隔离,客户端首屏自动水合,避免模块级 ref 造成的跨请求串扰;
// - 在 Nuxt 上下文外(如个别 composable 在模块加载期即被实例化)回退到模块级 ref,避免 useState 脱离 Nuxt 实例而报错。
// 注意:服务端的模块级 ref 为所有请求共享,但它仅在「无 Nuxt 上下文」的模块初始化期被读取(此时尚无请求态语言),
// 且 setLocale 在服务端不会写它(见下),因此只会返回兜底默认值,不存在跨请求语言串扰。
const fallbackLocaleRef = ref<Locale>(FALLBACK_LOCALE)
const getCurrentLocale = () =>
tryUseNuxtApp()
? useState<Locale>('voicehub-locale', () => fallbackLocaleRef.value)
: fallbackLocaleRef
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
Object.prototype.toString.call(value) === '[object Object]'
const mergeLocaleFallback = <T>(fallbackValue: T, currentValue: unknown): T => {
if (Array.isArray(fallbackValue) || typeof fallbackValue === 'function') {
return (currentValue ?? fallbackValue) as T
}
if (!isPlainObject(fallbackValue)) {
return (currentValue ?? fallbackValue) as T
}
const currentObject = isPlainObject(currentValue) ? currentValue : {}
const merged = { ...fallbackValue } as Record<string, unknown>
for (const key of Object.keys(currentObject)) {
merged[key] = currentObject[key]
}
for (const [key, value] of Object.entries(fallbackValue)) {
merged[key] = mergeLocaleFallback(value, currentObject[key])
}
return merged as T
}
const getLocaleSection = <Key extends LocaleSectionKey>(
localeMessages: LocaleMessages,
key: Key
) => {
const section = localeMessages[key]
// 兼容早期迁移时 auth/ui/songs 被临时挂在 pages 下的结构,避免 SSR 首屏读取空对象崩溃。
if (
section === undefined &&
(key === 'auth' || key === 'ui' || key === 'songs') &&
isPlainObject(localeMessages.pages)
) {
return localeMessages.pages[key as LegacyNestedSectionKey] as LocaleMessages[Key]
}
return section
}
// 合并结果缓存:词典内容不可变,(语言, 段) 的深合并结果只需计算一次。
// 仅在目标语言词典「已真正加载」后才缓存,避免把「加载完成前的临时回退结果」错误固化。
const mergedSectionCache = new Map<string, unknown>()
const getMergedSection = <Key extends LocaleSectionKey>(
locale: Locale,
key: Key
): LocaleMessages[Key] => {
const loaded = loadedMessages.value[locale]
const currentMessages = loaded ?? zhMessages
const computeMerged = () =>
mergeLocaleFallback(getLocaleSection(zhMessages, key), getLocaleSection(currentMessages, key))
// 目标语言尚未加载完成时,实时合并且不缓存;待加载完成后再固化正确结果。
if (!loaded) return computeMerged()
const cacheKey = `${locale}:${String(key)}`
const cached = mergedSectionCache.get(cacheKey)
if (cached !== undefined) return cached as LocaleMessages[Key]
const result = computeMerged()
mergedSectionCache.set(cacheKey, result)
return result
}
export function setLocale(locale: Locale, manual = false) {
if (!isSupportedLocale(locale)) return
// 与读取路径解耦:存在 Nuxt 上下文时更新 useState(请求隔离),
// 客户端另同步模块级回退 ref,避免两者出现 split-brain 导致部分消费者收不到切换。
if (tryUseNuxtApp()) {
useState<Locale>('voicehub-locale', () => fallbackLocaleRef.value).value = locale
// 语言与偏好来源写入 cookie:manual 长期记住;system 仅本次会话,下次进入重新跟随系统语言
const localeCookie = useCookie<Locale | undefined>(LOCALE_COOKIE_KEY, localeCookieOptions)
const preferenceCookie = useCookie<LocalePreference | undefined>(
LOCALE_PREFERENCE_KEY,
localeCookieOptions
)
localeCookie.value = locale
preferenceCookie.value = manual ? 'manual' : 'system'
}
// 触发目标语言词典的按需加载;加载完成后相关 computed 会自动更新。
void loadLocaleMessages(locale)
if (import.meta.client) {
// 客户端同步模块级回退 ref,使在模块加载期实例化的单例(如共享歌词实例)也能响应语言切换。
fallbackLocaleRef.value = locale
if (manual) {
try {
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale)
window.localStorage.setItem(LOCALE_PREFERENCE_KEY, 'manual')
} catch {
// 忽略隐私模式等场景下的持久化失败
}
}
}
}
// 切换到跟随系统模式:清除手动偏好并立即按系统语言切换。
export function followSystemLocale(): Locale {
if (import.meta.client) {
try {
window.localStorage.removeItem(LOCALE_STORAGE_KEY)
window.localStorage.removeItem(LOCALE_PREFERENCE_KEY)
} catch {
// 忽略隐私模式等场景下的清除失败
}
}
// 清除偏好后再解析,确保取到的是系统语言而非历史手动选择
const resolved = import.meta.client ? resolveClientInitialLocale() : FALLBACK_LOCALE
setLocale(resolved, false)
return resolved
}
export function useLocale() {
const currentLocale = getCurrentLocale()
// 偏好来源标志(manual=用户手动选择 / system=跟随系统);无 Nuxt 上下文(模块加载期)时视为跟随系统
const preferenceCookie = tryUseNuxtApp()
? useCookie<LocalePreference | undefined>(LOCALE_PREFERENCE_KEY, localeCookieOptions)
: null
// 当前是否为「跟随系统」模式(未手动选择过语言)
const isFollowingSystem = computed(() => preferenceCookie?.value !== 'manual')
const withFallback = <Key extends keyof LocaleMessages>(key: Key) =>
computed(() => getMergedSection(currentLocale.value, key))
return {
currentLocale,
isFollowingSystem,
followSystemLocale,
supportedLocales,
setLocale,
loadLocaleMessages,
siteConfig: withFallback('siteConfig'),
changePassword: withFallback('changePassword'),
common: withFallback('common'),
pages: withFallback('pages'),
auth: withFallback('auth'),
ui: withFallback('ui'),
audioPlayer: withFallback('audioPlayer'),
composableErrors: withFallback('composableErrors'),
songs: withFallback('songs'),
admin: withFallback('admin'),
yearReview: withFallback('yearReview'),
serverErrors: withFallback('serverErrors'),
theme: withFallback('theme') as unknown as typeof import('./zh-CN').theme,
importantNotification: withFallback('importantNotification')
}
}
File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
/**
* @fileoverview 跟元数据清理器有关的配置内容
*/
/**
* 默认的关键字列表,不包含冒号
*
* 建议更新这里的列表后运行 scripts\sort-keywords.ts 来排序并去重列表
*/
export const keywords = [
'版权',
'贝斯',
'贝斯Bass',
'本歌曲商用授权,前往小程序',
'编曲',
'编曲 Arranger',
'编曲Arranged By',
'编曲Arrangement',
'策划',
'策划 Planner',
'策划统筹',
'策划统筹Planner and coordinator',
'出品',
'出品、发行',
'出品/发行',
'出品/发行方',
'出品/发行公司',
'出品方Presented by',
'出品方Presenter',
'出品公司',
'出品公司Production Company',
'出品人',
'出品人 Publisher',
'出品人Chief Producer',
'出品人Presenter',
'出品人Producer',
'出品团队',
'出品团队Production Team',
'出品Present by',
'词',
'词 Lyricist',
'词曲提供',
'词曲提供Lyrics & Composition Provided by',
'词曲提供Lyrics and Composition Provided by',
'词曲提供Music and Lyrics Provided by',
'词曲提供Words and Music by',
'词曲协力',
'词Lyrics',
'大提琴',
'大提琴 Cello',
'灯光设计',
'第二小提琴',
'第二小提琴 Second Violin',
'第一小提琴',
'第一小提琴 First Violin',
'发行',
'发行方',
'发行方Publisher',
'发行公司',
'发行人',
'发行Distributed by',
'发行Distribution',
'发行Release',
'发行Released by',
'翻唱',
'封面',
'封面插画Cover illustration',
'封面设计',
'封面设计 Cover Design',
'钢琴/合成器演奏',
'钢琴演奏',
'歌名',
'歌手',
'工作室',
'鼓',
'鼓Drums',
'合成器演奏',
'合声',
'合声编写',
'和声',
'和声 Backing Vocals',
'和声/和声设计',
'和声编写',
'和声编写Backing Vocal Arrangement',
'和声编写Backing Vocals Design',
'和声演唱',
'和声Backing Vocal',
'和音',
'和音 Bvox',
'混缩',
'混音',
'混音 Mixed by',
'混音 Mixing Engineer',
'混音/母带工程师',
'混音工程师',
'混音工程师Mixing Engineer',
'混音师',
'混音室',
'混音室Mixing Studio',
'吉他',
'吉他 Guitar',
'吉他Guitars',
'监制',
'监制 Deputy Executive Producer',
'监制Chief Producer',
'监制Executive Director',
'监制Supervised production',
'键盘',
'乐队统筹',
'乐器演奏',
'乐团',
'联合策划',
'联合出品',
'联合出品Co-produced by',
'联合出品Co-production',
'联合出品Jointly Produced by',
'联合出品Published',
'联合推广',
'录/混音',
'录音',
'录音 Recording Engineer',
'录音师',
'录音师Recording Engineer',
'录音室',
'录音室 Recording Studio',
'录音制作',
'鸣谢',
'母带',
'母带 Mastered by',
'母带处理工程师',
'母带工程师',
'母带工程师Mastering Engineer',
'母带后期处理工程师',
'母带后期处理工程师Mastering Engineer',
'母带后期处理录音室',
'母带后期处理录音室Mastering Studio',
'母带后期制作人',
'母带后期制作人Mastering Producer',
'配唱制作人',
'配唱制作人 Vocals Producer',
'配唱制作人Vocal Producer',
'企划',
'企划宣传',
'企划宣传Propaganda',
'企划营销',
'企划制作A&R Planning',
'企划Planning',
'曲',
'曲 Composer',
'设计',
'设计Design',
'特别鸣谢',
'特别鸣谢/艺人支持',
'特别鸣谢Acknowledgement',
'特别企划',
'题记',
'统筹',
'统筹Coordinator',
'统筹Planning',
'推广',
'推广策划',
'推广策划Marketing Strategy',
'推广策划Promotion Planning',
'推广策划Promotion Strategy',
'推广统筹 Promotion Coordinator',
'推广宣传',
'推广营销 Marketing Promotion',
'维伴音乐',
'文案',
'文案Copywriting',
'弦乐',
'弦乐 Strings',
'弦乐编写',
'弦乐编写Strings Arrangement',
'弦乐监制',
'弦乐录音师',
'弦乐录音室',
'项目统筹',
'项目统筹 Project Coordinator',
'宣传',
'宣传/推广',
'宣推',
'演唱',
'艺术指导',
'艺术指导Art Director',
'音乐项目总监Project Executive',
'音乐制作',
'音乐制作发行',
'音乐制作Music Production',
'音频编辑',
'营销推广',
'营销推广/出品/发行',
'营销推广Marketing',
'营销推广Marketing Promotion',
'原唱',
'制作发行',
'制作公司',
'制作公司 Production',
'制作公司/OP',
'制作公司Produce Company',
'制作人',
'制作人 Producer',
'制作人Music Producer',
'制作人Record Producer',
'制作统筹',
'制作统筹Executive Producer',
'制作团队',
'制作助理',
'中提琴',
'中提琴 Viola',
'专辑',
'总策划',
'总策划 Chief Planner',
'总策划Chref Planner',
'总监制 Chief Executive Producer',
'总企划',
'作词',
'作曲',
'Acknowledgement',
'Additional Vocal by',
'Arranged By',
'Arrangement',
'Arranger',
'Art Director',
'Artist',
'Background Vocals by',
'Backing Vocal',
'Backing Vocal Arrangement',
'Backing Vocals',
'Backing Vocals Design',
'Bass',
'Cello',
'Chief Producer',
'Chref Planner',
'Co-produced by',
'Co-Producer',
'Co-production',
'Composed by',
'Composer',
'Copywriting',
'Cover Design',
'Design',
'Distributed by',
'Distribution',
'Drums',
'Executive Producer',
'First Violin',
'Guitar',
'Guitars',
'Jointly Produced by',
'Lyricist',
'Lyrics',
'Lyrics & Composition Provided by',
'Lyrics and Composition Provided by',
'Lyrics by',
'Marketing Promotion',
'Marketing Strategy',
'Mastered by',
'Mastering Engineer',
'Mixing Engineer',
'Music and Lyrics Provided by',
'Music Production',
'OP',
'OP、SP',
'OP/发行',
'OP/SP',
'Planner',
'Planner and coordinator',
'Presented by',
'Presenter',
'Produce Company',
'Produced by',
'Producer',
'Production Company',
'Production Team',
'Promotion Planning',
'Promotion Strategy',
'Propaganda',
'Published by',
'Publisher',
'Record Producer',
'Recorded at',
'Recording Engineer',
'Recording Studio',
'Release',
'Released by',
'Repertoire Owner',
'Second Violin',
'Songs Title',
'SP',
'Strings',
'Strings Arrangement',
'Supervised production',
'Viola',
'Vocal Engineer',
'Vocal Producer',
'Vocals by',
'Vocals Produced by',
'Words and Music by',
'Written by'
]
/**
* 默认的正则表达式
*/
export const regexes = [
'(?:【.*?音乐人.*?】|\\(.*?音乐人.*?\\)|「.*?音乐人.*?」|(.*?音乐人.*?)|『.*?音乐人.*?』)',
'.*?未经.*?不得.*?'
]
+230
View File
@@ -0,0 +1,230 @@
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
import { stripLyricMetadata, type StripOptions } from './lyricStripper'
import { keywords as defaultKeywords, regexes as defaultRegexes } from './exclude'
import { cloneDeep } from 'lodash-es'
/**
* 获取替换配置
*/
const getReplacementConfig = (settings: any) => {
const preset = settings.bracketReplacementPreset.value || 'dash'
const custom = settings.customBracketReplacement.value || '-'
let startStr = ' - '
const endStr = ' '
const isEnclosure = false
// 预设模式
if (preset === 'angleBrackets') {
return { startStr: '〔', endStr: '〕', isEnclosure: true }
} else if (preset === 'cornerBrackets') {
return { startStr: '「', endStr: '」', isEnclosure: true }
} else if (preset === 'custom') {
const trimmed = custom.trim()
// 启发式判断:如果是两个不同字符且不包含减号,视为成对符号 (如 "()")
const isPair = trimmed.length === 2 && trimmed[0] !== trimmed[1] && !trimmed.includes('-')
if (isPair) {
return { startStr: trimmed[0], endStr: trimmed[1], isEnclosure: true }
} else {
// 否则视为分隔符 (如 "-"),加上空格规范化
startStr = ' ' + trimmed + ' '
startStr = startStr.replace(/\s+/g, ' ')
return { startStr, endStr: ' ', isEnclosure: false }
}
} else if (preset === 'space') {
return { startStr: ' ', endStr: ' ', isEnclosure: false }
}
// 默认 dash 模式
return { startStr, endStr, isEnclosure }
}
/**
* 应用括号替换逻辑
*/
const applyBracketReplacement = (lines: LyricLine[], settings: any): LyricLine[] => {
if (!settings.replaceLyricBrackets.value) return lines
const { startStr, endStr, isEnclosure } = getReplacementConfig(settings)
// 正则:匹配全行括号 (如 "(Music)")
const fullBracketRegex = /^\s*[((][^()()]*[))]\s*$/
// 正则:匹配左括号
const leftBracketRegex = /[((]/g
// 正则:匹配右括号
const rightBracketRegex = /[))]/g
/**
* 处理普通字符串 (用于翻译和罗马音)
*/
const processString = (str: string): string => {
if (!str) return str
// 如果是全行括号且非闭合模式 (如分隔符模式),则直接去除括号
if (!isEnclosure && fullBracketRegex.test(str)) {
return str
.replace(/^\s*[((]/, '')
.replace(/[))]\s*$/, '')
.trim()
}
let res = str.replace(leftBracketRegex, startStr)
if (isEnclosure) {
res = res.replace(rightBracketRegex, endStr)
} else {
// 分隔符模式:右括号如果是结尾则移除,否则替换为结束符(空格)
res = res.replace(/[))](?=\s*$)/g, '').replace(rightBracketRegex, endStr)
// 清理重复分隔符 (如 " - - " -> " - ")
if (startStr.includes('-')) {
res = res.replace(/(?:\s*-\s*){2,}/g, ' - ')
}
}
return res
}
return lines.map((line) => {
// 处理 words
if (line.words) {
const fullText = line.words.map((w) => w.word).join('')
const isFullBracket = fullBracketRegex.test(fullText)
// 整行是括号内容,且处于分隔符模式 (非闭合) -> 直接移除首尾括号,不加分隔符
if (isFullBracket && !isEnclosure) {
let removedStart = false
let removedEnd = false
// 移除第一个左括号
for (const word of line.words) {
if (!removedStart && leftBracketRegex.test(word.word)) {
word.word = word.word.replace(leftBracketRegex, '')
removedStart = true
}
}
// 移除最后一个右括号
for (let i = line.words.length - 1; i >= 0; i--) {
const word = line.words[i]
if (!removedEnd && rightBracketRegex.test(word.word)) {
const lastIndex = Math.max(word.word.lastIndexOf(')'), word.word.lastIndexOf(')'))
if (lastIndex !== -1) {
word.word = word.word.substring(0, lastIndex) + word.word.substring(lastIndex + 1)
removedEnd = true
}
}
}
} else {
// 普通替换逻辑
line.words.forEach((word, index) => {
// 替换左括号
word.word = word.word.replace(leftBracketRegex, startStr)
if (isEnclosure) {
// 闭合模式:直接替换右括号
word.word = word.word.replace(rightBracketRegex, endStr)
} else {
// 分隔符模式:
word.word = word.word.replace(rightBracketRegex, (_, offset, str) => {
// 如果右括号在单词末尾,且是整句的最后一个单词 -> 移除 (视为句尾闭合)
const isAtWordEnd = offset === str.length - 1
const isAtLineEnd = index === line.words.length - 1
if (isAtWordEnd) {
return isAtLineEnd ? '' : endStr
}
return endStr
})
}
})
// 清理逻辑 (仅针对带减号的分隔符模式)
if (!isEnclosure && startStr.includes('-')) {
line.words.forEach((word, index) => {
// 单词内重复: " - - " -> " - "
word.word = word.word.replace(/(?:\s*-\s*){2,}/g, ' - ')
// 单词间重复: 前一个词以 "- " 结尾,当前词以 " -" 开头 -> 移除前一个的尾部
if (index > 0) {
const prev = line.words[index - 1]
if (/-\s*$/.test(prev.word) && /^\s*-/.test(word.word)) {
prev.word = prev.word.replace(/-\s*$/, '')
// 确保当前词开头有完整分隔符
if (!/^\s*-\s+/.test(word.word)) {
word.word = ' - ' + word.word.replace(/^\s*-\s*/, '')
}
}
}
})
}
}
}
// 处理翻译和罗马音
if (line.translatedLyric) line.translatedLyric = processString(line.translatedLyric)
if (line.romanLyric) line.romanLyric = processString(line.romanLyric)
return line
})
}
/**
* 格式化歌词
* 主要处理括号替换、关键词过滤等
* @param lines 歌词行
* @param settings 歌词设置对象 (从 useLyricSettings 获取)
* @param metadata 可选的歌曲元数据,用于更精确的歌词清洗
*/
export const formatLyric = (
lines: LyricLine[],
settings: any,
metadata?: { title?: string; artists?: string[] }
): LyricLine[] => {
const { enableExcludeLyrics, excludeLyricsUserKeywords, excludeLyricsUserRegexes } = settings
if (!lines || lines.length === 0) return []
// 使用 cloneDeep 避免修改原始引用 (如果需要)
// 但 formatLyric 通常被视为纯函数处理流程,这里我们操作的是 parsedLyrics 的结果
// 为了安全起见,克隆一份
let processedLines = cloneDeep(lines)
// 1. 智能元数据清洗 (Smart Metadata Stripping)
if (enableExcludeLyrics && enableExcludeLyrics.value) {
const userKeywords = excludeLyricsUserKeywords.value || []
const userRegexes = excludeLyricsUserRegexes.value || []
const mergedKeywords = [...new Set([...defaultKeywords, ...userKeywords])]
const mergedRegexes = [...new Set([...defaultRegexes, ...userRegexes])]
const options: StripOptions = {
keywords: mergedKeywords,
regexPatterns: mergedRegexes,
matchMetadata: metadata
}
processedLines = stripLyricMetadata(processedLines, options)
}
// 2. 基础过滤 (保留)
processedLines = processedLines.filter((line) => {
const text = line.words.map((w) => w.word).join('')
const transText = line.translatedLyric || ''
if (!text.trim()) {
if (!transText.trim() || transText.includes('著作权') || transText.includes('QQ音乐')) {
return false
}
}
if (text.includes('著作权') || text.includes('QQ音乐')) return false
if (transText.includes('著作权') || transText.includes('QQ音乐') || transText.trim() === '//') {
line.translatedLyric = ''
}
return true
})
// 3. 括号替换
processedLines = applyBracketReplacement(processedLines, settings)
return processedLines
}
+76
View File
@@ -0,0 +1,76 @@
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
/** 日语假名:平假名 + 片假名 + 半角假名 + 促音/长音符号 */
const KANA_RE = /[\p{Script=Hiragana}\p{Script=Katakana}\u30FC\uFF66-\uFF9F]/u
/** 韩文:谚文音节 + 谚文字母 + 谚文兼容字母 */
const HANGUL_RE = /[\p{Script=Hangul}\u3130-\u318F]/u
/** 中日韩统一表意文字(含扩展区) */
const HAN_RE = /\p{Script=Han}/u
/** 拉丁字母;数字与标点不能作为英文判断依据 */
const LATIN_RE = /\p{Script=Latin}/u
type LanguageSourceLine = Pick<LyricLine, 'words'> & Partial<Pick<LyricLine, 'translatedLyric'>>
/** 判断是否有实质内容的翻译歌词 */
const hasTranslation = (line: LanguageSourceLine): boolean =>
(line.translatedLyric ?? '').trim().length > 0
/**
* 基于整首歌词上下文推断每行语言,返回与行数组对齐的 BCP 47 语言标签数组
*
* Han 脚本无法独立区分中日韩;
* - 同一首歌词出现假名时,通常将纯汉字行视为日语;
* - 同一首歌词出现谚文时,通常将纯汉字行视为韩语;
* - CJK 混合启发式规则:若所有包含假名/谚文的行均有翻译,
* 则认定全为汉字且无翻译的行为中文,以此区分双语混合歌词;
* - 拉丁文字使用 BCP 47 的 und-Latn,避免误标为英语;
* - 无法判断时返回 undefined(不设置 lang 属性)。
*
* @param lines 已解析的整首歌词
*/
export const detectLyricLanguages = (
lines: ReadonlyArray<LanguageSourceLine>
): Array<string | undefined> => {
const lineContents = lines.map((line) => line.words.map((word) => word.word).join(''))
let hasKana = false
let hasHangul = false
let kanaUntranslatedCount = 0
let hangulUntranslatedCount = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const content = lineContents[i] ?? ''
const isTranslated = line ? hasTranslation(line) : false
if (KANA_RE.test(content)) {
hasKana = true
if (!isTranslated) kanaUntranslatedCount++
}
if (HANGUL_RE.test(content)) {
hasHangul = true
if (!isTranslated) hangulUntranslatedCount++
}
}
const allKanaTranslated = hasKana && kanaUntranslatedCount === 0
const allHangulTranslated = hasHangul && hangulUntranslatedCount === 0
return lines.map((line, i) => {
const content = lineContents[i] ?? ''
const isTranslated = hasTranslation(line)
if (KANA_RE.test(content)) return 'ja'
if (HANGUL_RE.test(content)) return 'ko'
if (HAN_RE.test(content)) {
if (hasKana) return allKanaTranslated && !isTranslated ? 'zh-CN' : 'ja'
if (hasHangul) return allHangulTranslated && !isTranslated ? 'zh-CN' : 'ko'
return 'zh-CN'
}
if (LATIN_RE.test(content)) return 'und-Latn'
return undefined
})
}
+298
View File
@@ -0,0 +1,298 @@
import { parseTTML, parseYrc } from '@applemusic-like-lyrics/lyric'
import { cleanTTMLTranslations, parseQRCLyric, parseSmartLrc } from './lyricParser'
const MIN_CONTENT_SCORE = 0.82
const MAX_ANCHOR_DIFF_MS = 3000
const MAX_P90_DIFF_MS = 2500
const MAX_TIMELINE_DRIFT_MS = 4000
const MIN_TIMELINE_RATIO = 0.985
const MAX_TIMELINE_RATIO = 1.015
const MIN_LINE_SIMILARITY = 0.68
const METADATA_LINE_RE =
/^(?:作词|作曲|编曲|制作人|混音|母带|录音|词|曲|composer|lyricist|arranger|producer|mixed by)\s*[::]/i
export type LyricMatchData = {
lrc?: string
yrc?: string
ttml?: string
}
type ComparableLine = {
text: string
startTime: number
endTime: number
}
type MatchAnchor = {
referenceStart: number
candidateStart: number
}
export type LyricMatchMetrics = {
durationDiffMs?: number
durationLimitMs?: number
contentScore?: number
anchorCount?: number
timelineCoverage?: number
withinTimeRatio?: number
p90DiffMs?: number
timelineDriftMs?: number
timelineRatio?: number
}
export type LyricMatchDecision = {
status: 'accepted' | 'rejected' | 'uncertain'
reason: string
metrics: LyricMatchMetrics
}
/** 归一化歌词正文,仅用于跨来源一致性比较 */
const normalizeText = (text: string): string =>
text
.normalize('NFKC')
.toLowerCase()
.replace(/[\p{P}\p{S}\s]+/gu, '')
/** 将不同解析器的行结构转为一致的比较结构 */
const toComparableLines = (lines: any[]): ComparableLine[] =>
lines.flatMap((line) => {
if (line?.isBG) return []
const raw = Array.isArray(line?.words)
? line.words
.map((word: any) => word?.word ?? word?.content ?? '')
.join('')
.trim()
: String(line?.content ?? '').trim()
if (!raw || METADATA_LINE_RE.test(raw)) return []
const text = normalizeText(raw)
if (text.length < 2) return []
const startTime = Number(line?.startTime ?? line?.time ?? 0)
const endTime = Number(line?.endTime ?? startTime)
return [{ text, startTime, endTime }]
})
/** 按格式优先级解析歌词正文 */
const parseComparableLyrics = (data: LyricMatchData): ComparableLine[] => {
if (data.ttml?.trim()) {
try {
const parsed = parseTTML(cleanTTMLTranslations(data.ttml)) as any
const lines = toComparableLines(parsed?.lines ?? [])
if (lines.length > 0) return lines
} catch {
// 继续尝试逐字或普通歌词
}
}
if (data.yrc?.trim()) {
const raw = data.yrc.trim()
try {
if (raw.startsWith('<')) {
const lines = toComparableLines(parseQRCLyric(raw))
if (lines.length > 0) return lines
} else {
const lines = toComparableLines(parseYrc(raw) ?? [])
if (lines.length > 0) return lines
}
} catch {
// 继续回退到通用 LRC
}
}
if (data.lrc?.trim()) {
try {
return toComparableLines(parseSmartLrc(data.lrc).lines)
} catch {
return []
}
}
return []
}
/** 计算两个短文本的归一化编辑相似度 */
const textSimilarity = (left: string, right: string): number => {
if (left === right) return 1
if (!left || !right) return 0
if (left.length < right.length) return textSimilarity(right, left)
const previous = new Uint16Array(right.length + 1)
const current = new Uint16Array(right.length + 1)
for (let j = 0; j <= right.length; j++) previous[j] = j
for (let i = 1; i <= left.length; i++) {
current[0] = i
for (let j = 1; j <= right.length; j++) {
current[j] = Math.min(
previous[j] + 1,
current[j - 1] + 1,
previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1)
)
}
previous.set(current)
}
return 1 - previous[right.length] / Math.max(left.length, right.length)
}
const spanLength = (lines: ComparableLine[], start: number, count: number): number =>
lines[start].text.length + (count === 2 ? lines[start + 1].text.length : 0)
const joinSpan = (lines: ComparableLine[], start: number, count: number): string =>
count === 1 ? lines[start].text : lines[start].text + lines[start + 1].text
/** 有序对齐歌词行,并允许相邻两行互相合并 */
const alignLines = (
reference: ComparableLine[],
candidate: ComparableLine[]
): { score: number; anchors: MatchAnchor[] } => {
const width = candidate.length + 1
const size = (reference.length + 1) * width
const scores = new Float64Array(size)
const prevReference = new Uint8Array(size)
const prevCandidate = new Uint8Array(size)
const update = (
fromReference: number,
fromCandidate: number,
referenceCount: number,
candidateCount: number,
addedScore: number
) => {
const nextReference = fromReference + referenceCount
const nextCandidate = fromCandidate + candidateCount
const from = fromReference * width + fromCandidate
const next = nextReference * width + nextCandidate
const score = scores[from] + addedScore
if (score <= scores[next]) return
scores[next] = score
prevReference[next] = referenceCount
prevCandidate[next] = candidateCount
}
for (let i = 0; i <= reference.length; i++) {
for (let j = 0; j <= candidate.length; j++) {
if (i < reference.length) update(i, j, 1, 0, 0)
if (j < candidate.length) update(i, j, 0, 1, 0)
for (let referenceCount = 1; referenceCount <= 2; referenceCount++) {
if (i + referenceCount > reference.length) break
for (let candidateCount = 1; candidateCount <= 2; candidateCount++) {
if (j + candidateCount > candidate.length) break
const referenceLength = spanLength(reference, i, referenceCount)
const candidateLength = spanLength(candidate, j, candidateCount)
if (
Math.min(referenceLength, candidateLength) /
Math.max(referenceLength, candidateLength) <
MIN_LINE_SIMILARITY
) {
continue
}
const referenceText = joinSpan(reference, i, referenceCount)
const candidateText = joinSpan(candidate, j, candidateCount)
const similarity = textSimilarity(referenceText, candidateText)
if (similarity < MIN_LINE_SIMILARITY) continue
update(
i,
j,
referenceCount,
candidateCount,
Math.min(referenceText.length, candidateText.length) * similarity
)
}
}
}
}
const anchors: MatchAnchor[] = []
let i = reference.length
let j = candidate.length
while (i > 0 || j > 0) {
const index = i * width + j
const referenceCount = prevReference[index]
const candidateCount = prevCandidate[index]
if (referenceCount === 0 && candidateCount === 0) break
const previousI = i - referenceCount
const previousJ = j - candidateCount
if (referenceCount > 0 && candidateCount > 0) {
anchors.push({
referenceStart: reference[previousI].startTime,
candidateStart: candidate[previousJ].startTime
})
}
i = previousI
j = previousJ
}
anchors.reverse()
return { score: scores[size - 1], anchors }
}
/** 检测高阶歌词是否与基准歌词属于同一录音版本 */
export const evaluateLyricDataMatch = (
referenceData: LyricMatchData,
candidateData: LyricMatchData,
referenceDuration?: number,
candidateDuration?: number
): LyricMatchDecision => {
const metrics: LyricMatchMetrics = {}
if (referenceDuration && candidateDuration) {
metrics.durationDiffMs = Math.abs(referenceDuration - candidateDuration)
metrics.durationLimitMs = Math.max(8000, referenceDuration * 0.04)
if (metrics.durationDiffMs > metrics.durationLimitMs) {
return { status: 'rejected', reason: 'duration_mismatch', metrics }
}
}
const reference = parseComparableLyrics(referenceData)
const candidate = parseComparableLyrics(candidateData)
if (reference.length < 3 || candidate.length < 3) {
return { status: 'uncertain', reason: 'insufficient_lines', metrics }
}
const aligned = alignLines(reference, candidate)
const referenceChars = reference.reduce((sum, line) => sum + line.text.length, 0)
const candidateChars = candidate.reduce((sum, line) => sum + line.text.length, 0)
metrics.contentScore = (2 * aligned.score) / (referenceChars + candidateChars)
if (metrics.contentScore < MIN_CONTENT_SCORE) {
return { status: 'rejected', reason: 'content_mismatch', metrics }
}
const anchors = aligned.anchors.filter(
(anchor) => Number.isFinite(anchor.referenceStart) && Number.isFinite(anchor.candidateStart)
)
metrics.anchorCount = anchors.length
const lastReference = reference[reference.length - 1]
const timelineDuration =
referenceDuration || lastReference.endTime || lastReference.startTime || 0
const minAnchors = timelineDuration > 0 && timelineDuration < 60000 ? 3 : 6
if (anchors.length < minAnchors) {
return { status: 'uncertain', reason: 'insufficient_anchors', metrics }
}
const first = anchors[0]
const last = anchors[anchors.length - 1]
const referenceSpan = last.referenceStart - first.referenceStart
const candidateSpan = last.candidateStart - first.candidateStart
metrics.timelineCoverage = timelineDuration > 0 ? referenceSpan / timelineDuration : 0
const minimumCoverage = timelineDuration > 0 && timelineDuration < 60000 ? 0.35 : 0.5
if (metrics.timelineCoverage < minimumCoverage) {
return { status: 'uncertain', reason: 'insufficient_timeline_coverage', metrics }
}
const differences = anchors
.map((anchor) => Math.abs(anchor.candidateStart - anchor.referenceStart))
.sort((left, right) => left - right)
metrics.withinTimeRatio =
differences.filter((difference) => difference <= MAX_ANCHOR_DIFF_MS).length / differences.length
metrics.p90DiffMs = differences[Math.max(0, Math.ceil(differences.length * 0.9) - 1)]
const firstOffset = first.candidateStart - first.referenceStart
const lastOffset = last.candidateStart - last.referenceStart
metrics.timelineDriftMs = Math.abs(lastOffset - firstOffset)
metrics.timelineRatio = referenceSpan > 0 ? candidateSpan / referenceSpan : 0
if (
metrics.withinTimeRatio < 0.8 ||
metrics.p90DiffMs > MAX_P90_DIFF_MS ||
metrics.timelineDriftMs > MAX_TIMELINE_DRIFT_MS ||
metrics.timelineRatio < MIN_TIMELINE_RATIO ||
metrics.timelineRatio > MAX_TIMELINE_RATIO
) {
return { status: 'rejected', reason: 'timeline_mismatch', metrics }
}
return { status: 'accepted', reason: 'matched', metrics }
}
+532
View File
@@ -0,0 +1,532 @@
import { cloneDeep } from 'lodash-es'
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
import { parseTTML as parseTTMLLib } from '@applemusic-like-lyrics/lyric'
import { extractLyricContent } from './qrc-parser'
import { parseLrc } from './parseLrc'
/**
* LRC 格式类型
*/
export enum LrcFormat {
/** 普通逐行 LRC */
Line = 'line',
/** 逐字 LRC:[00:28.850]曲[00:32.455]:[00:36.060]钱 */
WordByWord = 'word-by-word',
/** 增强型 LRC (ESLyric):[01:37.305]<01:37.624>怕<01:37.943>你 */
Enhanced = 'enhanced'
}
/** LyricWord 类型 */
type LyricWord = { word: string; startTime: number; endTime: number; romanWord: string }
// 预编译正则表达式
const META_TAG_REGEX = /^\[[a-z]+:/i
const TIME_TAG_REGEX = /\[(\d{2}):(\d{2})\.(\d{1,})\]/g
const ENHANCED_TIME_TAG_REGEX = /<(\d{2}):(\d{2})\.(\d{1,})>/
// 移除全局带状态的正则,改为在函数内使用 matchAll 或重新构建
const LINE_TIME_REGEX = /^\[(\d{2}):(\d{2})\.(\d{1,})\]/
// QRC 解析相关正则 - 提前编译
const QRC_LINE_PATTERN = /^\[(\d+),(\d+)\](.*)$/
const QRC_WORD_PATTERN = /([^(]*)\((\d+),(\d+)\)/g
const DEFAULT_WORD_DURATION = 1000
const ALIGN_TOLERANCE_MS = 300
/** 二次最近邻对齐的漂移容差:兜底时间轴整体漂移较大的来源 */
const DRIFT_TOLERANCE_MS = 1500
/**
* 解析时间戳为毫秒
* 使用字符串补齐处理,避免浮点数计算误差
*/
const parseTimeToMs = (min: string, sec: string, ms: string): number => {
const minutes = parseInt(min, 10)
const seconds = parseInt(sec, 10)
// 补齐到 3 位 (例如 "5" -> "500", "05" -> "050", "1234" -> "123")
const msNormalized = ms.padEnd(3, '0').slice(0, 3)
const milliseconds = parseInt(msNormalized, 10)
return minutes * 60 * 1000 + seconds * 1000 + milliseconds
}
/**
* 创建 LyricWord 对象
*/
const createWord = (word: string, startTime: number, endTime: number = startTime): LyricWord => ({
word,
startTime,
endTime,
romanWord: ''
})
/**
* 创建 LyricLine 对象
*/
const createLine = (words: LyricWord[], startTime: number, endTime: number = 0): LyricLine => ({
words,
startTime,
endTime,
translatedLyric: '',
romanLyric: '',
isBG: false,
isDuet: false
})
/**
* 检测 LRC 格式类型
*/
export const detectLrcFormat = (content: string): LrcFormat => {
const lines = content.split(/\r?\n/)
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line || META_TAG_REGEX.test(line)) continue
// 检查增强型LRC
if (ENHANCED_TIME_TAG_REGEX.test(line)) {
return LrcFormat.Enhanced
}
// 检查逐字LRC
const matches = line.match(TIME_TAG_REGEX)
if (matches && matches.length > 1) {
return LrcFormat.WordByWord
}
}
return LrcFormat.Line
}
/**
* 解析逐字 LRC 格式
* 优化:在解析过程中直接计算 endTime,避免二次遍历
*/
export const parseWordByWordLrc = (content: string): LyricLine[] => {
const result: LyricLine[] = []
let prevLine: LyricLine | null = null
const WORD_BY_WORD_PATTERN = /\[(\d{2}):(\d{2})\.(\d{1,})\]([^[\\]]*)/g
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || META_TAG_REGEX.test(line)) continue
const words: LyricWord[] = []
let lineStartTime = Infinity
let prevWord: LyricWord | null = null
const matches = line.matchAll(WORD_BY_WORD_PATTERN)
for (const match of matches) {
const startTime = parseTimeToMs(match[1], match[2], match[3])
const wordText = match[4]
if (!wordText && words.length === 0) continue
lineStartTime = Math.min(lineStartTime, startTime)
// 设置上一个字的结束时间
if (prevWord) {
prevWord.endTime = startTime
}
if (wordText) {
const newWord = createWord(wordText, startTime)
words.push(newWord)
prevWord = newWord
}
}
// 处理行内最后一个字
if (prevWord) {
prevWord.endTime = prevWord.startTime + DEFAULT_WORD_DURATION
}
if (words.length > 0) {
const lineObj = createLine(words, lineStartTime === Infinity ? 0 : lineStartTime)
// 设置行结束时间为最后一个字的结束时间
lineObj.endTime = words[words.length - 1].endTime
// 修正上一行的结束时间 (Single Pass)
if (prevLine) {
const prevLastWord = prevLine.words[prevLine.words.length - 1]
// 只有当当前行开始时间晚于上一行最后一个字的开始时间时,才进行截断
if (lineObj.startTime > prevLastWord.startTime) {
prevLastWord.endTime = Math.min(prevLastWord.endTime, lineObj.startTime)
prevLine.endTime = prevLastWord.endTime
}
}
result.push(lineObj)
prevLine = lineObj
}
}
return result
}
/**
* 解析增强型 LRC 格式 (ESLyric)
*/
export const parseEnhancedLrc = (content: string): LyricLine[] => {
const result: LyricLine[] = []
let prevLine: LyricLine | null = null
const ENHANCED_WORD_PATTERN = /<(\d{2}):(\d{2})\.(\d{1,})>([^<]*)/g
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || META_TAG_REGEX.test(line)) continue
const lineTimeMatch = LINE_TIME_REGEX.exec(line)
if (!lineTimeMatch) continue
const lineStartTime = parseTimeToMs(lineTimeMatch[1], lineTimeMatch[2], lineTimeMatch[3])
const contentAfterTime = line.slice(lineTimeMatch[0].length)
const words: LyricWord[] = []
// 检查是否有增强型标记
if (ENHANCED_TIME_TAG_REGEX.test(contentAfterTime)) {
let prevWord: LyricWord | null = null
const matches = contentAfterTime.matchAll(ENHANCED_WORD_PATTERN)
for (const match of matches) {
const startTime = parseTimeToMs(match[1], match[2], match[3])
const wordText = match[4]
if (prevWord) {
prevWord.endTime = startTime
}
if (wordText) {
const newWord = createWord(wordText, startTime)
words.push(newWord)
prevWord = newWord
}
}
if (prevWord) {
prevWord.endTime = prevWord.startTime + DEFAULT_WORD_DURATION // 默认兜底
}
} else {
// 无增强型标记,作为整行处理
const text = contentAfterTime.trim()
if (text) {
words.push(createWord(text, lineStartTime, lineStartTime + DEFAULT_WORD_DURATION)) // 默认持续1s
}
}
if (words.length > 0) {
const lineObj = createLine(words, lineStartTime)
lineObj.endTime = words[words.length - 1].endTime
// 修正上一行的结束时间 (Single Pass)
if (prevLine) {
const prevLastWord = prevLine.words[prevLine.words.length - 1]
if (lineObj.startTime > prevLastWord.startTime) {
prevLastWord.endTime = Math.min(prevLastWord.endTime, lineObj.startTime)
prevLine.endTime = prevLastWord.endTime
}
}
result.push(lineObj)
prevLine = lineObj
}
}
return result
}
/**
* 智能解析 LRC 歌词
*/
export const parseSmartLrc = (content: string): { format: LrcFormat; lines: LyricLine[] } => {
const format = detectLrcFormat(content)
let lines: LyricLine[]
switch (format) {
case LrcFormat.WordByWord:
lines = parseWordByWordLrc(content)
break
case LrcFormat.Enhanced:
lines = parseEnhancedLrc(content)
break
default:
lines = parseLrc(content) || []
}
console.log(`[LyricParser] 检测到歌词格式: ${format}, 共 ${lines.length} 行`)
return { format, lines }
}
/**
* 判断解析结果是否为逐字格式
*/
export const isWordLevelFormat = (format: LrcFormat): boolean =>
format === LrcFormat.WordByWord || format === LrcFormat.Enhanced
/**
* 歌词内容对齐
* 两阶段策略:
* 1. 双指针严格匹配(容差 300ms),与主/翻译行数不一致时按时间方向跳跃
* 2. 对仍未命中的主行,与剩余未使用的翻译行做最近邻匹配(容差放宽到 1500ms)
* 兜底时间轴整体漂移较大的来源(vkeys、第三方翻译 LRC 等),
* 避免个别行漂移导致大面积翻译丢失
* @param lyrics 歌词数据 (Readonly)
* @param otherLyrics 其他歌词数据
* @param key 对齐类型
* @returns 对齐后的歌词数据 (新副本)
*/
export const alignLyrics = (
lyrics: Readonly<LyricLine[]>,
otherLyrics: Readonly<LyricLine[]>,
key: 'translatedLyric' | 'romanLyric'
): LyricLine[] => {
if (!lyrics.length || !otherLyrics.length) return cloneDeep(lyrics) as LyricLine[]
const result = cloneDeep(lyrics) as LyricLine[]
// 防御性排序:部分解析路径(逐字 LRC)不保证输出按时间有序
const others = [...otherLyrics].sort((a, b) => a.startTime - b.startTime)
const textOf = (line: LyricLine): string => line.words.map((word) => word.word).join('')
const used = new Set<number>()
// 第一轮:双指针严格匹配
let i = 0
let j = 0
while (i < result.length && j < others.length) {
const diff = result[i].startTime - others[j].startTime
if (Math.abs(diff) <= ALIGN_TOLERANCE_MS) {
result[i][key] = textOf(others[j])
used.add(j)
i++
j++
} else if (diff < 0) {
i++
} else {
j++
}
}
// 第二轮:最近邻兜底(1:1 约束,只填充第一轮未命中的行)
for (let m = 0; m < result.length; m++) {
if (result[m][key]) continue
let bestK = -1
let bestDiff = Infinity
for (let k = 0; k < others.length; k++) {
if (used.has(k)) continue
const diff = Math.abs(result[m].startTime - others[k].startTime)
if (diff < bestDiff) {
bestDiff = diff
bestK = k
}
}
if (bestK !== -1 && bestDiff <= DRIFT_TOLERANCE_MS) {
result[m][key] = textOf(others[bestK])
used.add(bestK)
}
}
return result
}
/**
* 解析 QRC 内容为行数据
*/
const parseQRCContent = (
rawContent: string
): Array<{
startTime: number
endTime: number
words: Array<{ word: string; startTime: number; endTime: number }>
}> => {
// 使用策略模式提取 LyricContent (自动适配 Browser/Node 环境)
const content = extractLyricContent(rawContent) || rawContent
const result: Array<{
startTime: number
endTime: number
words: Array<{ word: string; startTime: number; endTime: number }>
}> = []
for (const rawLine of content.split('\n')) {
const line = rawLine.trim()
if (!line) continue
// 跳过元数据标签 [ti:xxx] [ar:xxx] 等
if (META_TAG_REGEX.test(line)) continue
const lineMatch = QRC_LINE_PATTERN.exec(line)
if (!lineMatch) continue
const lineStart = parseInt(lineMatch[1], 10)
const lineDuration = parseInt(lineMatch[2], 10)
const lineContent = lineMatch[3]
// 解析逐字
const words: Array<{ word: string; startTime: number; endTime: number }> = []
const matches = lineContent.matchAll(QRC_WORD_PATTERN)
for (const match of matches) {
const wordText = match[1]
const wordStart = parseInt(match[2], 10)
const wordDuration = parseInt(match[3], 10)
if (wordText) {
words.push({
word: wordText,
startTime: wordStart,
endTime: wordStart + wordDuration
})
}
}
if (words.length > 0) {
result.push({
startTime: lineStart,
endTime: lineStart + lineDuration,
words
})
}
}
return result
}
/**
* 解析 QQ 音乐 QRC 格式歌词
* @param qrcContent QRC 原始内容
* @param trans 翻译歌词
* @param roma 罗马音歌词(QRC 格式)
* @returns LyricLine 数组
*/
export const parseQRCLyric = (qrcContent: string, trans?: string, roma?: string): LyricLine[] => {
// 解析主歌词
const qrcLines = parseQRCContent(qrcContent)
let result: LyricLine[] = qrcLines.map((qrcLine) => {
return {
words: qrcLine.words.map((word) => ({
...word,
romanWord: ''
})),
startTime: qrcLine.startTime,
endTime: qrcLine.endTime,
translatedLyric: '',
romanLyric: '',
isBG: false,
isDuet: false
}
})
// 处理翻译
if (trans) {
// parseSmartLrc 兼容 1 位毫秒时间戳与逐字变体,比 parseLrc 更稳
let { lines: transLines } = parseSmartLrc(trans)
if (transLines?.length) {
// 过滤包含 "//" 或 "作品的著作权" 的翻译行
transLines = transLines.filter((line) => {
const text = line.words.map((w) => w.word).join('')
return !text.includes('//') && !text.includes('作品的著作权')
})
result = alignLyrics(result, transLines, 'translatedLyric')
}
}
// 处理音译
if (roma) {
const qrcRomaLines = parseQRCContent(roma)
if (qrcRomaLines?.length) {
const romaLines: LyricLine[] = qrcRomaLines.map((line) => {
return {
words: [
{
startTime: line.startTime,
endTime: line.endTime,
word: line.words.map((w) => w.word).join(''),
romanWord: ''
}
],
startTime: line.startTime,
endTime: line.endTime,
translatedLyric: '',
romanLyric: '',
isBG: false,
isDuet: false
}
})
result = alignLyrics(result, romaLines, 'romanLyric')
}
}
return result
}
/**
* 清洗 TTML 中不需要的翻译
* @param ttmlContent 原始 TTML 内容
* @returns 清洗后的 TTML 内容
*/
export const cleanTTMLTranslations = (ttmlContent: string): string => {
const lang_counter = (ttml_text: string) => {
// 使用正则匹配所有 xml:lang="xx-XX" 格式的字符串
const langRegex = /(?<=<(span|translation)[^<>]+)xml:lang="([^"]+)"/g
const matches = ttml_text.matchAll(langRegex)
// 提取匹配结果并去重
const langSet = new Set<string>()
for (const match of matches) {
if (match[2]) langSet.add(match[2])
}
return Array.from(langSet)
}
const lang_filter = (langs: string[]): string | null => {
if (langs.length <= 1) return null
const lang_matcher = (target: string) => {
return langs.find((lang) => {
try {
// @ts-expect-error Intl.Locale type definition is missing maximize method
return new Intl.Locale(lang).maximize().script === target
} catch {
return false
}
})
}
const hans_matched = lang_matcher('Hans')
if (hans_matched) return hans_matched
const hant_matched = lang_matcher('Hant')
if (hant_matched) return hant_matched
const major = langs.find((key) => key.startsWith('zh'))
if (major) return major
return langs[0]
}
const ttml_cleaner = (ttml_text: string, major_lang: string | null): string => {
// 如果没有指定主语言,直接返回原文本(或者根据需求返回空)
if (major_lang === null) return ttml_text
/**
* 替换逻辑回调函数
* @param match 完整匹配到的标签字符串
* @param lang 正则中第一个捕获组匹配到的语言代码
*/
const replacer = (match: string, lang: string) => (lang === major_lang ? match : '')
const translationRegex = /<translation[^>]+xml:lang="([^"]+)"[^>]*>[\s\S]*?<\/translation>/g
const spanRegex = /<span[^>]+xml:lang="([^" ]+)"[^>]*>[\s\S]*?<\/span>/g
return ttml_text.replace(translationRegex, replacer).replace(spanRegex, replacer)
}
const context_lang = lang_counter(ttmlContent)
const major = lang_filter(context_lang)
const cleaned_ttml = ttml_cleaner(ttmlContent, major)
return cleaned_ttml.replace(/\n\s*/g, '')
}
/**
* 简单的 TTML 解析器 (Wrapper for library)
*/
export const parseTTML = (content: string) => {
return parseTTMLLib(content)
}
+411
View File
@@ -0,0 +1,411 @@
/**
* @fileoverview 元数据行清理器
*
* 用于清理歌词中开头和结尾的元数据行,例如:
*
* (歌曲名) - (歌手名)
* 词:...
* 曲:...
* 编曲:...
* 和声编唱:...
* 人声编辑:...
* 混音:...
* 母带:...
* 监制:...
* 出品:...
* 真正的歌词行 1
* 真正的歌词行 2
*
* @see https://github.com/apoint123/Unilyric/blob/afd351c54eca7137cf8ee4ea5652d9ee55c20e32/lyrics_helper_rs/src/converter/processors/metadata_stripper.rs
*/
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
const STRICT_MATCH_SEPARATORS = [
':',
':',
',',
',',
'.',
'。',
'!',
'!',
'-',
'_',
'(',
'(',
'[',
'【',
'{',
'『',
'「'
]
/**
* 扫描限制配置
*/
export interface ScanLimitConfig {
/**
* 扫描比例 (0.0 - 1.0)
*/
ratio: number
/**
* 最小扫描行数
*/
minLines: number
/**
* 最大扫描行数
*/
maxLines: number
}
/**
* 歌词清理配置
*/
export interface StripOptions {
/**
* 要匹配的关键词列表
*/
keywords: string[]
/**
* 要匹配的正则字符串列表
*/
regexPatterns: string[]
/**
* 这里的正则表达式会被视为弱匹配
*/
softMatchRegexes?: string[]
/**
* 歌曲元数据,用于检查第一行是否为 "歌曲 - 歌手" 格式
*/
matchMetadata?: {
title?: string
artists?: string[]
}
}
const DEFAULT_HEADER_LIMIT: ScanLimitConfig = {
ratio: 0.2,
minLines: 20,
maxLines: 70
}
const DEFAULT_FOOTER_LIMIT: ScanLimitConfig = {
ratio: 0.2,
minLines: 20,
maxLines: 50
}
function calculateScanLimit(config: ScanLimitConfig, totalLines: number): number {
const proportional = Math.ceil(totalLines * config.ratio)
return Math.min(Math.min(Math.max(proportional, config.minLines), config.maxLines), totalLines)
}
function getLineText(line: LyricLine): string {
if (!line || !line.words) return ''
return line.words
.map((w) => w.word)
.join('')
.trim()
}
/**
* 清理文本以便检查
*
* 移除行首尾的括号,例如 (作曲: xxx) -> 作曲: xxx
*/
function cleanTextForCheck(text: string): string {
let processed = text.trim()
const brackets = [
['(', ')'],
['(', ')'],
['【', '】'],
['[', ']'],
['{', '}'],
['『', '』'],
['「', '」']
]
let changed = true
let loopCount = 0
while (changed && loopCount < 5) {
changed = false
loopCount++
for (const [open, close] of brackets) {
if (processed.startsWith(open)) {
// 类似于这样的格式:(作曲:周杰伦)
if (processed.endsWith(close)) {
processed = processed.slice(open.length, processed.length - close.length).trim()
changed = true
break
}
// 类似于这样的格式:(Live) 作曲:周杰伦
const closeIdx = processed.indexOf(close)
if (closeIdx > -1) {
const contentAfter = processed.slice(closeIdx + close.length).trim()
if (contentAfter.length > 0) {
processed = contentAfter
changed = true
break
}
}
}
}
}
return processed
}
// 强匹配行:匹配关键词加冒号,或者匹配正则表达式的行
// 弱匹配行:带有冒号,但不匹配关键词或正则表达式的行。如果夹在强匹配行之间,多半是元数据行但是没有对应的规则。但也有可能是演唱者标识,“男:...”这样的,为了避免误删,如果后面全是弱匹配行,就不删它们
// 真正的歌词行:既不匹配规则,又没有冒号的行,作为防火墙来阻止对之后行的移除。避免元数据在歌词中间,把中间的歌词也移除了
function isStrictMatch(text: string, keywords: string[], regexes: RegExp[]): boolean {
const cleaned = cleanTextForCheck(text)
// 转小写并移除空格进行匹配
const normalizedText = cleaned.toLowerCase().replace(/\s+/g, '')
for (const kw of keywords) {
const normalizedKw = kw.toLowerCase().replace(/\s+/g, '')
if (normalizedText.startsWith(normalizedKw)) {
const remainder = normalizedText.slice(normalizedKw.length)
// 如果剩余部分为空,说明完全匹配
if (remainder.length === 0) {
return true
}
// 检查分隔符
// 允许的分隔符包括:冒号、逗号、句号、感叹号、连字符、括号等
// 注意:normalizedText 已经移除了空格
if (STRICT_MATCH_SEPARATORS.includes(remainder.charAt(0))) {
return true
}
}
}
for (const reg of regexes) {
if (reg.test(text)) {
return true
}
}
return false
}
function looksLikeMetadata(text: string, softRegexes: RegExp[]): boolean {
const cleaned = cleanTextForCheck(text)
if (
cleaned.includes(':') ||
cleaned.includes(':') ||
// 第一行的 歌曲名 - 歌手名 这样的格式
cleaned.includes('-')
) {
return true
}
for (const reg of softRegexes) {
if (reg.test(text)) {
return true
}
}
return false
}
/**
* 扫描头部,寻找正文开始的位置
*/
function findHeaderCutoff(
lines: readonly LyricLine[],
startIndex: number,
keywords: string[],
regexes: RegExp[],
softRegexes: RegExp[],
limit: number
): number {
let lastValidMetadataIndex = startIndex - 1
console.groupCollapsed(`[LyricStripper] ⬇️ 开始头部扫描 (Start: ${startIndex}, Limit: ${limit})`)
for (let i = startIndex; i < limit; i++) {
if (i >= lines.length) break
const text = getLineText(lines[i])
if (!text) {
continue
}
const strict = isStrictMatch(text, keywords, regexes)
const weak = looksLikeMetadata(text, softRegexes)
let status = '❌ NONE'
if (strict) status = '✅ STRICT'
else if (weak) status = '⚠️ WEAK'
console.log(`Line [${i}]: "${text}" | Result: ${status}`)
if (!strict && !weak) {
break
}
if (strict) {
lastValidMetadataIndex = i
}
}
console.groupEnd()
return lastValidMetadataIndex + 1
}
/**
* 扫描尾部,寻找正文结束的位置
*/
function findFooterCutoff(
lines: readonly LyricLine[],
startIndex: number,
keywords: string[],
regexes: RegExp[],
softRegexes: RegExp[],
limit: number
): number {
if (startIndex >= lines.length) return startIndex
const scanEnd = Math.max(startIndex, lines.length - limit)
let firstValidFooterIndex = lines.length
console.groupCollapsed(`[LyricStripper] ⬆️ 开始尾部扫描 (Limit: ${limit})`)
for (let i = lines.length - 1; i >= scanEnd; i--) {
const text = getLineText(lines[i])
if (!text) {
continue
}
const strict = isStrictMatch(text, keywords, regexes)
const weak = looksLikeMetadata(text, softRegexes)
let status = '❌ NONE'
if (strict) status = '✅ STRICT'
else if (weak) status = '⚠️ WEAK'
console.log(`Line [${i}]: "${text}" | Result: ${status}`)
if (!strict && !weak) {
break
}
if (strict) {
firstValidFooterIndex = i
}
}
console.groupEnd()
return firstValidFooterIndex
}
/**
* 剥离歌词中的元数据行
* @param lines 原始歌词行数组
* @param options 包含关键词和正则的配置
* @returns 清理后的新歌词行数组
*/
export function stripLyricMetadata(
lines: readonly LyricLine[],
options: StripOptions
): LyricLine[] {
if (!lines || lines.length === 0) return []
let scanStartIndex = 0
if (options.matchMetadata) {
const { title, artists } = options.matchMetadata
const firstLineText = getLineText(lines[0])
if (title && artists && artists.length > 0 && firstLineText) {
const lowerText = firstLineText.toLowerCase()
const lowerTitle = title.toLowerCase()
if (lowerText.includes(lowerTitle)) {
const hasAnyArtist = artists.some((artist) => lowerText.includes(artist.toLowerCase()))
if (hasAnyArtist) {
console.log(
`[LyricStripper] 在第一行匹配到歌曲元数据: "${firstLineText}" (Title: ${title}, Artists: ${artists.join(', ')})`
)
scanStartIndex = 1
}
}
}
}
if (
scanStartIndex === 0 &&
(!options.keywords || options.keywords.length === 0) &&
(!options.regexPatterns || options.regexPatterns.length === 0) &&
(!options.softMatchRegexes || options.softMatchRegexes.length === 0)
) {
return [...lines]
}
const regexes: RegExp[] = []
if (options.regexPatterns) {
options.regexPatterns.forEach((p) => {
try {
if (p.trim()) {
regexes.push(new RegExp(p, 'i')) // 忽略大小写
}
} catch (e) {
console.warn(`[LyricStripper] 无效的正则表达式: ${p}`, e)
}
})
}
const softRegexes: RegExp[] = []
if (options.softMatchRegexes) {
options.softMatchRegexes.forEach((p) => {
try {
if (p.trim()) softRegexes.push(new RegExp(p, 'i'))
} catch (e) {
console.warn(`[LyricStripper] 无效的正则表达式: ${p}`, e)
}
})
}
const keywords = options.keywords || []
const totalLines = lines.length
const headerConfig = DEFAULT_HEADER_LIMIT
const headerLimit = calculateScanLimit(headerConfig, totalLines)
const startIdx = findHeaderCutoff(
lines,
scanStartIndex,
keywords,
regexes,
softRegexes,
headerLimit
)
const footerConfig = DEFAULT_FOOTER_LIMIT
const footerLimit = calculateScanLimit(footerConfig, totalLines)
const endIdx = findFooterCutoff(lines, startIdx, keywords, regexes, softRegexes, footerLimit)
if (startIdx === 0 && endIdx === lines.length) {
return [...lines]
}
const newLength = endIdx - startIdx
console.log(`[LyricStripper] 清理完成,总行数从 ${totalLines} 变为 ${newLength}`)
return lines.slice(startIdx, endIdx)
}
+69
View File
@@ -0,0 +1,69 @@
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
const TIME_TAG_REGEX = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/g
const META_TAG_REGEX = /^\[[a-z]+:/i
export const parseLrc = (lrcContent: string): LyricLine[] => {
if (!lrcContent) return []
const lines = lrcContent.split(/\r?\n/)
const result: LyricLine[] = []
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine) continue
// 跳过元数据
if (META_TAG_REGEX.test(trimmedLine)) continue
const matches = [...trimmedLine.matchAll(TIME_TAG_REGEX)]
if (matches.length === 0) continue
// 提取歌词文本(移除所有时间标签)
const text = trimmedLine.replace(TIME_TAG_REGEX, '').trim()
// 允许空行(作为间奏),但通常我们忽略完全空的行,除非为了显示间隔
if (!text) continue
// 处理一行多个时间标签的情况(重复歌词)
for (const match of matches) {
const min = parseInt(match[1], 10)
const sec = parseInt(match[2], 10)
// 补齐毫秒
const msStr = match[3].padEnd(3, '0').slice(0, 3)
const ms = parseInt(msStr, 10)
const startTime = min * 60 * 1000 + sec * 1000 + ms
result.push({
startTime,
endTime: 0, // 稍后计算
words: [{ word: text, startTime, endTime: 0, romanWord: '' }],
translatedLyric: '',
romanLyric: '',
isBG: false,
isDuet: false
})
}
}
// 按时间排序
result.sort((a, b) => a.startTime - b.startTime)
// 计算结束时间
for (let i = 0; i < result.length; i++) {
const line = result[i]
const nextLine = result[i + 1]
if (nextLine) {
line.endTime = nextLine.startTime
} else {
line.endTime = line.startTime + 5000 // 最后一行默认 5秒
}
// 更新单词结束时间
if (line.words.length > 0) {
line.words[0].endTime = line.endTime
}
}
return result
}
+64
View File
@@ -0,0 +1,64 @@
/**
* QRC 解析
*/
// 定义函数签名类型
type QRCParserFn = (xmlStr: string) => string
// 实现 DOM 策略
const domParser: QRCParserFn = (xmlStr) => {
if (!xmlStr || !xmlStr.trim()) return ''
try {
const parser = new DOMParser()
const doc = parser.parseFromString(xmlStr, 'text/xml')
// 检查解析错误(如未转义的 & 等)
const parseError = doc.querySelector('parsererror')
if (parseError) {
// DOM 解析失败,回退到正则解析
return regexParser(xmlStr)
}
return doc.documentElement.getAttribute('LyricContent') || ''
} catch (e) {
console.error('[QRC Parser] Fatal Error:', e)
// 回退到正则解析
return regexParser(xmlStr)
}
}
const decodeXml = (str: string) => {
return str
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
}
// 实现正则策略
const regexParser: QRCParserFn = (xmlStr) => {
if (!xmlStr) return ''
// 尝试贪婪匹配 (处理 LyricContent="... "..." ..." 这种非标准 XML 引号未转义的情况)
// 匹配从 LyricContent=" 开始,直到标签结束前的最后一个引号
const greedyMatch = /LyricContent\s*=\s*"([\s\S]*)"\s*\/?>/.exec(xmlStr)
if (greedyMatch) {
const content = greedyMatch[1]
// 启发式检查:如果提取的内容中包含类似 ` Attribute="` 的结构,说明贪婪匹配吃掉了其他属性
// 这种情况下回退到非贪婪匹配
if (!/\s+\w+\s*=\s*"/.test(content)) {
return decodeXml(content)
}
}
// 标准非贪婪匹配 (兼容标准 XML 属性)
const match = /LyricContent\s*=\s*"([^"]*)"/.exec(xmlStr)
// 如果没有匹配到 XML 结构,假设输入本身就是内容 (保持原有逻辑的兼容性)
const result = match?.[1] || xmlStr
return decodeXml(result)
}
// 避免每次调用时的运行时检查
export const extractLyricContent: QRCParserFn =
typeof DOMParser !== 'undefined' ? domParser : regexParser
+300
View File
@@ -0,0 +1,300 @@
/**
* 歌词数据适配器
* 将现有的歌词数据格式转换为 WordByWordLyrics 组件所需的 AMLL 格式
*/
import type { ParsedLyricLine as ExistingLyricLine } from '~/composables/useLyrics'
// AMLL 格式的接口定义
export interface LyricWord {
startTime: number
endTime: number
word: string
romanWord: string
obscene: boolean
}
export interface AdapterLyricLine {
words: LyricWord[]
translatedLyric: string
romanLyric: string
startTime: number
endTime: number
isBG: boolean
isDuet: boolean
}
/**
* 将现有的歌词数据转换为 AMLL 格式
* @param existingLyrics 现有的歌词数据
* @param translationLyrics 翻译歌词数据
* @param options 转换选项
*/
export function convertToAmllFormat(
existingLyrics: ExistingLyricLine[],
translationLyrics: ExistingLyricLine[] = [],
options: {
enableWordByWord?: boolean
defaultDuration?: number
romanization?: boolean
} = {}
): AdapterLyricLine[] {
const { enableWordByWord = true, defaultDuration = 1000, romanization = false } = options
return existingLyrics.map((line, index) => {
// 计算行的结束时间
const nextLine = existingLyrics[index + 1]
const endTime = nextLine ? nextLine.time : line.time + defaultDuration
// 查找对应的翻译歌词
const translation = findMatchingTranslation(line, translationLyrics)
// 处理逐字歌词
const words = processWords(line, endTime, enableWordByWord)
return {
words,
translatedLyric:
translation &&
translation.content &&
translation.content.trim() &&
translation.content !== '//'
? translation.content
: '',
romanLyric: romanization ? generateRomanization(line.content) : '',
startTime: line.time,
endTime,
isBG: false, // 可以根据需要扩展
isDuet: false // 可以根据需要扩展
}
})
}
/**
* 处理单行歌词的词汇数据
*/
function processWords(
line: ExistingLyricLine,
lineEndTime: number,
enableWordByWord: boolean
): LyricWord[] {
// 如果有逐字歌词数据,直接使用
if (enableWordByWord && line.words && line.words.length > 0) {
return line.words.map((word) => ({
startTime: word.time,
endTime: word.time + word.duration,
word: word.content,
romanWord: '',
obscene: false
}))
}
// 如果没有逐字歌词,将整行作为一个词处理
if (line.content.trim()) {
return [
{
startTime: line.time,
endTime: lineEndTime,
word: line.content,
romanWord: '',
obscene: false
}
]
}
return []
}
/**
* 查找匹配的翻译歌词
*/
function findMatchingTranslation(
line: ExistingLyricLine,
translations: ExistingLyricLine[]
): ExistingLyricLine | null {
if (!translations.length) return null
// 查找时间最接近的翻译
let bestMatch: ExistingLyricLine | null = null
let minTimeDiff = Infinity
for (const translation of translations) {
const timeDiff = Math.abs(translation.time - line.time)
if (timeDiff < minTimeDiff) {
minTimeDiff = timeDiff
bestMatch = translation
}
}
// 如果时间差超过5秒,认为不匹配
return minTimeDiff <= 5000 ? bestMatch : null
}
/**
* 生成音译(简单实现,可以根据需要扩展)
*/
function generateRomanization(text: string): string {
// 这里可以集成更复杂的音译逻辑
// 目前返回空字符串,可以根据需要实现
return ''
}
/**
* 智能分词处理(用于没有逐字歌词的情况)
*/
export function smartWordSegmentation(
text: string,
startTime: number,
endTime: number,
options: {
minWordDuration?: number
maxWordDuration?: number
} = {}
): LyricWord[] {
const { minWordDuration = 200, maxWordDuration = 2000 } = options
if (!text.trim()) return []
// 分词逻辑:
// 1. 对于英文,按空格分割单词
// 2. 对于中文,只在句末标点符号处分割,避免拆分歌名中的数字和符号
// 检测是否包含英文字母
const hasEnglish = /[a-zA-Z]/.test(text)
let segments: string[]
if (hasEnglish) {
// 英文内容:按空格和句末标点符号分割
segments = text.split(/(\s+|[.!?。!?])/g).filter((s) => s.trim())
} else {
// 中文内容:只按句末标点符号分割,避免拆分歌名中的数字和符号
segments = text.split(/([。!?])/g).filter((s) => s.trim() && !/^[。!?\s]+$/.test(s))
}
// 如果没有明显的分割点,或者分割后只有一个片段,将整行作为一个词处理
if (segments.length <= 1) {
return [
{
startTime,
endTime,
word: text.trim(),
romanWord: '',
obscene: false
}
]
}
const totalDuration = endTime - startTime
const avgDuration = Math.max(
minWordDuration,
Math.min(maxWordDuration, totalDuration / segments.length)
)
return segments.map((segment, index) => {
const wordStartTime = startTime + index * avgDuration
const wordEndTime = Math.min(wordStartTime + avgDuration, endTime)
return {
startTime: wordStartTime,
endTime: wordEndTime,
word: segment.trim(),
romanWord: '',
obscene: false
}
})
}
/**
* 检测和标记背景歌词
*/
export function detectBackgroundLyrics(lyrics: AdapterLyricLine[]): AdapterLyricLine[] {
return lyrics.map((line) => {
// 简单的背景歌词检测逻辑
const isBG =
line.words.length === 0 ||
line.words.every((word) => word.word.trim().length === 0) ||
(line.translatedLyric.includes('(') && line.translatedLyric.includes(')'))
return {
...line,
isBG
}
})
}
/**
* 检测和标记对唱歌词
*/
export function detectDuetLyrics(lyrics: AdapterLyricLine[]): AdapterLyricLine[] {
return lyrics.map((line) => {
// 简单的对唱检测逻辑
const isDuet =
line.translatedLyric.includes('(对唱)') ||
line.translatedLyric.includes('(合唱)') ||
line.words.some((word) => word.word.includes('(') && word.word.includes(')'))
return {
...line,
isDuet
}
})
}
/**
* 完整的歌词数据转换流程
*/
export function fullLyricConversion(
existingLyrics: ExistingLyricLine[],
translationLyrics: ExistingLyricLine[] = [],
options: {
enableWordByWord?: boolean
enableSmartSegmentation?: boolean
enableBackgroundDetection?: boolean
enableDuetDetection?: boolean
romanization?: boolean
} = {}
): AdapterLyricLine[] {
const {
enableWordByWord = true,
enableSmartSegmentation = true,
enableBackgroundDetection = true,
enableDuetDetection = true,
romanization = false
} = options
// 基础转换
let convertedLyrics = convertToAmllFormat(existingLyrics, translationLyrics, {
enableWordByWord,
romanization
})
// 智能分词(对于没有逐字歌词的行)
if (enableSmartSegmentation) {
convertedLyrics = convertedLyrics.map((line) => {
if (line.words.length === 1 && line.words[0].word === line.words[0].word.trim()) {
const smartWords = smartWordSegmentation(line.words[0].word, line.startTime, line.endTime)
if (smartWords.length > 1) {
return {
...line,
words: smartWords
}
}
}
return line
})
}
// 背景歌词检测
if (enableBackgroundDetection) {
convertedLyrics = detectBackgroundLyrics(convertedLyrics)
}
// 对唱歌词检测
if (enableDuetDetection) {
convertedLyrics = detectDuetLyrics(convertedLyrics)
}
return convertedLyrics
}
+83
View File
@@ -0,0 +1,83 @@
import { Marked } from 'marked'
import xssPkg from 'xss'
const { escapeAttrValue, filterXSS, friendlyAttrValue, safeAttrValue, whiteList } = xssPkg
const ALLOWED_LINK_SCHEMES = new Set(['http', 'https', 'mailto', 'tel'])
const ALLOWED_IMAGE_SCHEMES = new Set(['http', 'https'])
const SAFE_RASTER_DATA_URL = /^data:image\/(?:avif|gif|jpe?g|png|webp);base64,[a-z0-9+/=\s]+$/i
const escapeHtml = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
const getUrlScheme = (value) => {
const colonIndex = value.indexOf(':')
if (colonIndex < 0) return null
const pathBoundaryIndexes = ['/', '?', '#']
.map((character) => value.indexOf(character))
.filter((index) => index >= 0)
const firstPathBoundary = pathBoundaryIndexes.length
? Math.min(...pathBoundaryIndexes)
: Number.POSITIVE_INFINITY
if (firstPathBoundary < colonIndex) return null
const scheme = value.slice(0, colonIndex).replace(/\s/g, '')
return /^[a-z][a-z0-9+.-]*$/i.test(scheme) ? scheme.toLowerCase() : 'invalid'
}
const isSafeUrl = (tag, name, value) => {
const scheme = getUrlScheme(value)
if (!scheme) return true
if (name === 'href') return ALLOWED_LINK_SCHEMES.has(scheme)
if (name === 'src') {
if (tag === 'img' && scheme === 'data') return SAFE_RASTER_DATA_URL.test(value)
return ALLOWED_IMAGE_SCHEMES.has(scheme)
}
return false
}
const sanitizeAttributeValue = (tag, name, value, cssFilter) => {
if (name !== 'href' && name !== 'src') {
return safeAttrValue(tag, name, value, cssFilter)
}
const normalizedValue = friendlyAttrValue(value).trim()
return isSafeUrl(tag, name, normalizedValue) ? escapeAttrValue(normalizedValue) : ''
}
// 创建独立的 marked 实例,避免全局污染和 HMR 重复注册
const markedInstance = new Marked({
gfm: true,
breaks: true,
renderer: {
html(token) {
const rawHtml = typeof token === 'string' ? token : token.text
return escapeHtml(rawHtml || '')
}
}
})
// xss 白名单:默认白名单 + GFM 任务列表 checkbox + code 类名(为语法高亮预留)
const xssOptions = {
whiteList: {
...whiteList,
input: ['type', 'checked', 'disabled'],
code: ['class']
},
safeAttrValue: sanitizeAttributeValue
}
/**
* 将 Markdown 文本渲染为安全 HTML
* 两层防护:marked 禁用原始 HTML + xss 清洗(禁 script、on* 事件、javascript: 协议)
*
* @param {string} text Markdown 文本
* @returns {string} 安全的 HTML 字符串
*/
export function renderMarkdown(text) {
if (!text || typeof text !== 'string') return ''
const rawHtml = markedInstance.parse(text)
return filterXSS(rawHtml, xssOptions)
}
+253
View File
@@ -0,0 +1,253 @@
/**
* 音源配置文件
* 定义音源接口、配置和默认设置
*/
/**
* 音源接口定义
*/
export interface MusicSource {
/** 音源唯一标识 */
id: string
/** 音源显示名称 */
name: string
/** API基础URL */
baseUrl: string
/** 优先级,数字越小优先级越高 */
priority: number
/** 是否启用 */
enabled: boolean
/** 请求超时时间(毫秒),可选 */
timeout?: number
/** 自定义请求头,可选 */
headers?: Record<string, string>
}
/**
* 音源配置接口
*/
export interface MusicSourceConfig {
/** 主音源ID */
primarySource: string
/** 是否启用故障转移 */
enableFailover: boolean
/** 音源列表 */
sources: MusicSource[]
/** 默认超时时间(毫秒) */
timeout: number
/** 重试次数 */
retryAttempts: number
}
/**
* 音源状态接口
*/
export interface SourceStatus {
/** 状态:在线、离线、错误 */
status: 'online' | 'offline' | 'error'
/** 响应时间(毫秒) */
responseTime?: number
/** 最后检查时间 */
lastCheck: Date
/** 错误信息 */
errorMessage?: string
}
/**
* 音乐搜索参数接口
*/
export interface MusicSearchParams {
/** 搜索关键词 */
keywords: string
/** 音乐平台:tencent=QQ音乐, netease=网易云音乐 */
platform?: string
/** 返回数量限制 */
limit?: number
/** 偏移量 */
offset?: number
/** 搜索类型:1=单曲, 10=专辑, 100=歌手, 1000=歌单, 1009=播客/电台, 2000=声音 */
type?: number
/** 网易云音乐Cookie */
cookie?: string
}
/**
* 音乐搜索结果接口
*/
export interface MusicSearchResult {
/** 是否成功 */
success: boolean
/** 使用的音源ID */
source: string
/** 搜索结果数据 */
data: any[]
/** 错误信息 */
error?: string
}
/**
* 歌曲详情参数接口
*/
export interface SongDetailParams {
/** 歌曲ID,可以是单个或多个 */
ids: string | string[]
}
/**
* 歌曲详情结果接口
*/
export interface SongDetailResult {
/** 是否成功 */
success: boolean
/** 使用的音源ID */
source: string
/** 歌曲详情数据 */
data: {
songs: any[]
}
/** 错误信息 */
error?: string
}
/**
* 默认音源配置
* 内置网易云和 QQ 音乐音源只保留已验证可用的请求路径。
*/
export const MUSIC_SOURCE_CONFIG: MusicSourceConfig = {
primarySource: 'netease-backup-1',
enableFailover: true,
timeout: 10000,
retryAttempts: 2,
sources: [
{
id: 'netease-backup-1',
name: 'NeteaseCloudMusicApi',
baseUrl: '/api/api-enhanced/netease',
priority: 1,
enabled: true,
timeout: 8000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
},
{
id: 'vkeys-v3',
name: 'Vkeys QQ 音乐 (v3)',
baseUrl: 'https://api.vkeys.cn/music',
priority: 2,
enabled: true,
timeout: 10000
},
{
id: 'vkeys',
name: 'Vkeys QQ 音乐 (v2)',
baseUrl: 'https://api.vkeys.cn/v2/music',
priority: 3,
enabled: true,
timeout: 10000
},
{
id: 'ygking-qq',
name: 'Ygking QQ 音乐备用',
baseUrl: 'https://api.ygking.top/api/song/url',
priority: 4,
enabled: true,
timeout: 8000
},
{
id: 'netease-gdstudio',
name: 'GD Studio 网易云',
baseUrl: 'https://music-api.gdstudio.xyz/api.php',
priority: 5,
enabled: true,
timeout: 10000
},
{
id: 'meting-1',
name: 'Meting API 网易云备用源1',
baseUrl: 'https://api.qijieya.cn/meting/',
priority: 6,
enabled: true,
timeout: 8000
},
{
id: 'meting-2',
name: 'Meting API 网易云备用源2',
baseUrl: 'https://api.injahow.cn/meting/',
priority: 7,
enabled: true,
timeout: 8000
},
{
id: 'bilibili',
name: '哔哩哔哩',
baseUrl: 'https://api.bilibili.com',
priority: 8,
enabled: true,
timeout: 10000
},
{
id: 'migu',
name: '咪咕音乐',
baseUrl: '/api/native-api/migu/playurl',
priority: 9,
enabled: true,
timeout: 10000
}
]
}
/**
* 获取启用的音源列表(按优先级排序)
*/
export function getEnabledSources(): MusicSource[] {
return MUSIC_SOURCE_CONFIG.sources
.filter((source) => source.enabled)
.sort((a, b) => a.priority - b.priority)
}
/**
* 根据ID获取音源
*/
export function getSourceById(id: string): MusicSource | undefined {
return MUSIC_SOURCE_CONFIG.sources.find((source) => source.id === id)
}
/**
* 获取主音源
*/
export function getPrimarySource(): MusicSource | undefined {
return getSourceById(MUSIC_SOURCE_CONFIG.primarySource)
}
/**
* 获取备用音源列表
*/
export function getBackupSources(): MusicSource[] {
return getEnabledSources().filter((source) => source.id !== MUSIC_SOURCE_CONFIG.primarySource)
}
/**
* 判断是否为纯数字(旧版 QQ 音乐数字 id)
*/
function isNumericId(id: string | number): boolean {
return /^\d+$/.test(String(id))
}
/**
* 构造 vkeys 请求的身份参数
* 网易云始终用 id;QQ 音乐旧版数字 id 用 id,新版 mid 用 mid
*/
export function getVkeysIdParam(
platform: 'netease' | 'tencent',
musicId: string | number
): { key: string; value: string } {
if (platform === 'netease') {
return { key: 'id', value: String(musicId) }
}
// tencent
if (isNumericId(musicId)) {
return { key: 'id', value: String(musicId) }
}
return { key: 'mid', value: String(musicId) }
}
+612
View File
@@ -0,0 +1,612 @@
import { useAudioQuality } from '~/composables/useAudioQuality'
import { useMusicSources } from '~/composables/useMusicSources'
import { useChkszSource } from '~/composables/useChkszSource'
import { getVkeysIdParam } from '~/utils/musicSources'
import { parseBilibiliId } from '~/utils/bilibiliSource'
/**
* 动态获取音乐播放URL
* @param platform 音乐平台 ('netease' | 'tencent')
* @param musicId 音乐ID
* @param playUrl 用户提供的播放链接(可选)
* @param options 额外选项,例如 { unblock: boolean, quality: number }
* @returns Promise<string | null> 返回播放URL或null
*/
export type MusicUrlResolveOptions = {
unblock?: boolean
quality?: number | string
mediaId?: string
excludeSources?: string[]
ignoreProvidedUrl?: boolean
musicInfo?: MusicTrackMeta
}
/**
* 歌曲元信息
*/
export type MusicTrackMeta = {
name?: string
artist?: string
album?: string
}
export type MusicUrlResolveResult = {
url: string | null
source?: string
normalizedMusicId?: string
idType?: string
}
export const INVALID_QQ_AUDIO_URL_SUFFIX = '/2149972737147268278.mp3'
// 星海音源(咪咕备用通道):由国内服务器代理获取链接,规避咪咕对海外 IP 的屏蔽
const XINGHAI_BASE_URL = 'https://yy.zddyr.top/lx/api/'
// 星海播放链接缓存(10 分钟),规避星海后端每分钟 10 次的限流
const XINGHAI_CACHE_TTL = 10 * 60 * 1000
// 星海请求滑动窗口限流(60 秒内最多 8 次,留余量规避后端 10 次/分钟限制)
const XINGHAI_RATE_LIMIT_MAX = 8
const XINGHAI_RATE_LIMIT_WINDOW = 60 * 1000
const xinghaiUrlCache = new Map<string, { url: string; expireAt: number }>()
// 同 key 请求合并,避免并发重复请求
const xinghaiInflight = new Map<string, Promise<string | null>>()
// 最近请求时间戳(滑动窗口限流)
const xinghaiRequestTimes: number[] = []
/**
* 通过星海音源获取咪咕播放链接
*/
const miguQuality = {
// 音质数值 → 服务端 toneFlag 映射
flagMap: { 1: 'PQ', 2: 'HQ', 3: 'SQ', 4: 'ZQ24' } as Record<number, string>,
upgradeUrl(url: string, flag: number) {
url = decodeURIComponent(url)
switch (flag) {
case 2: //HQ
return url.replace('MP3_128_16_Stero', 'MP3_320_16_Stero')
case 3: //SQ
return url.replace('标清高清/MP3_128_16_Stero', '歌曲下载/flac').replace('.mp3', '.flac')
case 4: //ZQ24 / ZQ
return url
.replace('标清高清/MP3_128_16_Stero', '歌曲下载/flac_24bit')
.replace('.mp3', '.flac')
case 1:
default:
return url
}
},
// 高音质资源可能缺失(404),按降级链逐级回退:ZQ24 → SQ → HQ → PQ
fallbackChain: { 4: [4, 3, 2, 1], 3: [3, 2, 1], 2: [2, 1], 1: [1] } as Record<number, number[]>,
/**
* 按降级链逐级升级并探测,返回第一个可用的链接
*/
async resolveAvailableUrl(rawUrl: string, flag: number): Promise<string> {
const base = decodeURIComponent(rawUrl)
const chain = this.fallbackChain[flag] || [1]
for (const level of chain) {
const candidate = this.upgradeUrl(rawUrl, level)
// PQ 为接口直出链接,无需探测
if (level === 1 || (await isMiguUrlAvailable(candidate))) {
if (level !== flag) {
console.warn(`[musicUrl] 咪咕 ${flag} 音质链接不可用,已降级为 ${level}`)
}
return candidate
}
}
return base
}
}
/**
* HEAD 探测链接可用性;跨域受限或网络异常时保守视为可用,避免误降级
*/
async function isMiguUrlAvailable(url: string): Promise<boolean> {
try {
const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
return res.ok
} catch {
return true
}
}
const fetchXinghaiMiguUrl = async (
contentId: string,
meta?: MusicTrackMeta,
miguFlag?: number
): Promise<string | null> => {
// 缓存需区分音质,避免不同音质请求互相命中返回错误音质的链接
const cacheKey = `migu:${contentId}:${miguFlag || 1}`
const cached = xinghaiUrlCache.get(cacheKey)
if (cached && cached.expireAt > Date.now()) {
return cached.url
}
// 清理过期缓存条目,避免无界增长
const now = Date.now()
for (const [key, entry] of xinghaiUrlCache) {
if (entry.expireAt <= now) {
xinghaiUrlCache.delete(key)
}
}
// 并发合并:同一歌曲的并发请求只发一次
const inflight = xinghaiInflight.get(cacheKey)
if (inflight) {
return inflight
}
// 滑动窗口限流:超限时直接失败,避免触发星海 429
const windowStart = now - XINGHAI_RATE_LIMIT_WINDOW
while (xinghaiRequestTimes.length > 0 && xinghaiRequestTimes[0] < windowStart) {
xinghaiRequestTimes.shift()
}
if (xinghaiRequestTimes.length >= XINGHAI_RATE_LIMIT_MAX) {
console.warn('[musicUrl] 星海音源请求过于频繁,跳过本次请求')
return null
}
const promise = (async (): Promise<string | null> => {
try {
xinghaiRequestTimes.push(Date.now())
const params: Record<string, string> = {
source: 'migu',
songmid: contentId,
quality: '128k'
}
if (meta?.name) params.name = meta.name
if (meta?.artist) params.singer = meta.artist
if (meta?.album) params.albumName = meta.album
const query = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&')
const response: any = await $fetch(`${XINGHAI_BASE_URL}?${query}`, {
timeout: 8000
})
if (response?.code === 200 && response?.url) {
let url = await miguQuality.resolveAvailableUrl(response.url.split('?')[0], miguFlag || 1)
if (url.startsWith('http://')) {
url = url.replace('http://', 'https://')
}
xinghaiUrlCache.set(cacheKey, { url, expireAt: Date.now() + XINGHAI_CACHE_TTL })
return url
}
} catch (error) {
console.warn('[musicUrl] 星海音源获取咪咕播放链接失败:', error)
}
return null
})()
xinghaiInflight.set(cacheKey, promise)
promise.finally(() => {
xinghaiInflight.delete(cacheKey)
})
return promise
}
const musicUrlSourceCache = new Map<string, string>()
const normalizeCacheUrl = (url: string) => {
return url.trim().replace(/^http:\/\//, 'https://')
}
export const isKnownInvalidQqAudioUrl = (url: string | null | undefined) => {
if (!url) return false
const normalizedUrl = normalizeCacheUrl(url)
const urlWithoutParams = normalizedUrl.split('?')[0].split('#')[0]
return urlWithoutParams.endsWith(INVALID_QQ_AUDIO_URL_SUFFIX)
}
const rememberMusicUrlSource = (url: string | null | undefined, source?: string) => {
if (!url || !source) return
musicUrlSourceCache.set(normalizeCacheUrl(url), source)
}
export const getCachedMusicUrlSource = (url: string | null | undefined) => {
if (!url) return null
return musicUrlSourceCache.get(normalizeCacheUrl(url)) || null
}
export async function getMusicUrlResult(
platform: string,
musicId: string | number,
playUrl?: string,
options?: MusicUrlResolveOptions
): Promise<MusicUrlResolveResult> {
// 如果用户提供了播放链接,优先使用
if (!options?.ignoreProvidedUrl && playUrl && playUrl.trim()) {
return {
url: playUrl.trim(),
source: 'play-url'
}
}
// 如果没有playUrl,但platform或musicId为空或无效,则无法获取播放链接
if (
!platform ||
!musicId ||
platform === 'unknown' ||
platform === '' ||
musicId === null ||
musicId === ''
) {
throw new Error('缺少音乐平台或音乐ID信息')
}
const { getQuality } = useAudioQuality()
const { getChkszMusicUrl, hasChkszKey } = useChkszSource()
// 优先使用 options 中的 quality,否则使用全局设置
const quality = options?.quality !== undefined ? options.quality : getQuality(platform)
// ChKSz 音源(用户自配 apikey):非 VIP 登录态下最优先,VIP 登录态下官方链路之后的次优先
const isExcluded = (source: string) => (options?.excludeSources || []).includes(source)
const tryChksz = async (chkszQuality: number | string): Promise<MusicUrlResolveResult | null> => {
if (!hasChkszKey() || isExcluded('chksz')) {
return null
}
const chkszUrl = await getChkszMusicUrl(platform, musicId, chkszQuality)
if (chkszUrl) {
rememberMusicUrlSource(chkszUrl, 'chksz')
return { url: chkszUrl, source: 'chksz' }
}
return null
}
if (platform === 'tencent') {
const normalizedQuality = Number(quality)
const qualityCandidates = [Number.isNaN(normalizedQuality) ? 8 : normalizedQuality]
const excludedSources = new Set(options?.excludeSources || [])
const qqMusicCookie =
typeof window !== 'undefined'
? window.localStorage.getItem('qq_music_cookie') || undefined
: undefined
// VIP 状态由登录校验接口写入(qq_music_vip),仅 VIP 才值得优先走官方链路
const isQqVip =
typeof window !== 'undefined' && window.localStorage.getItem('qq_music_vip') === '1'
const requestBackendResolve = async (
strategy: 'official-first' | 'fallback' = 'fallback'
) => {
const response: any = await $fetch('/api/music/resolve-url', {
method: 'POST',
body: {
platform,
musicId: String(musicId),
quality,
mediaId: options?.mediaId,
playUrl: options?.ignoreProvidedUrl ? undefined : playUrl,
cookie: qqMusicCookie,
excludeSources: [...excludedSources],
strategy
}
})
if (response?.success && response?.url) {
if (platform === 'tencent' && qqMusicCookie && response.authUsed === false) {
console.warn('[musicUrl] 已检测到 QQ 音乐本地登录态,但后端解析未使用登录 Cookie')
}
rememberMusicUrlSource(response.url, response.source)
return {
url: response.url,
source: response.source,
normalizedMusicId: response.normalizedMusicId,
idType: response.idType
}
}
throw new Error(response?.message || 'QQ音乐播放链接解析失败')
}
// VIP 登录态:优先后端用用户 Cookie 走官方链路换取直链,失败再降级第三方音源
if (qqMusicCookie && isQqVip && !excludedSources.has('qq-official')) {
try {
return await requestBackendResolve('official-first')
} catch (error: any) {
console.warn('[musicUrl] QQ 官方登录态解析失败,降级第三方音源:', error?.message || error)
}
}
// ChKSz 音源:VIP 官方之后的次优先,非 VIP/未登录时为最优先
const chkszResult = await tryChksz(qualityCandidates[0])
if (chkszResult) {
return chkszResult
}
// K×H 音源提取的 QQ 直连接口支持浏览器跨域,优先于 vkeys 使用。
if (!excludedSources.has('ygking-qq')) {
const ygkingQuality: Record<number, string> = {
4: '128',
8: '320',
10: 'flac',
11: 'master',
14: 'master'
}
const qualityKey = ygkingQuality[qualityCandidates[0]] || '320'
try {
const data: any = await fetch(
`https://api.ygking.top/api/song/url?mid=${encodeURIComponent(String(musicId))}&quality=${encodeURIComponent(qualityKey)}`,
{ signal: AbortSignal.timeout(5000) }
).then((result) => (result.ok ? result.json() : null))
const url =
data?.data?.[String(musicId)] || (data?.data && data.data[Object.keys(data.data)[0]])
if (data?.code === 0 && url && !isKnownInvalidQqAudioUrl(String(url))) {
rememberMusicUrlSource(String(url), 'ygking-qq')
return { url: String(url), source: 'ygking-qq' }
}
} catch {
// 继续尝试后端 HYW 与 vkeys。
}
}
if (!excludedSources.has('hyw-tx')) {
try {
return await requestBackendResolve()
} catch (error: any) {
console.warn('[musicUrl] HYW 后端解析失败,继续尝试 vkeys 音源:', error?.message || error)
}
}
// v3 负责探测歌曲支持的音质,播放链接仍由 v2 获取。
if (!excludedSources.has('vkeys-v3') && !excludedSources.has('vkeys')) {
try {
const idParam = getVkeysIdParam('tencent', musicId)
const infoResponse: any = await fetch(
`https://api.vkeys.cn/music/tencent/song/info?${idParam.key}=${encodeURIComponent(idParam.value)}`,
{ signal: AbortSignal.timeout(5000) }
).then((response) => (response.ok ? response.json() : null))
const qualityInfo = Array.isArray(infoResponse?.data?.qualityInfo)
? infoResponse.data.qualityInfo
: []
const selectedQuality = [...new Set([qualityCandidates[0], 8, 4, 10, 11, 14])].find(
(candidate) =>
qualityInfo.some(
(item: any) => Number(item.type) === candidate && Number(item.size) > 0
)
)
if (infoResponse?.code === 0 && selectedQuality !== undefined) {
const v2Response: any = await fetch(
`https://api.vkeys.cn/v2/music/tencent?${idParam.key}=${encodeURIComponent(idParam.value)}&quality=${selectedQuality}`,
{ signal: AbortSignal.timeout(5000) }
).then((response) => (response.ok ? response.json() : null))
const url = v2Response?.data?.url
if (v2Response?.code === 200 && url && !isKnownInvalidQqAudioUrl(String(url))) {
rememberMusicUrlSource(String(url), 'vkeys-v3')
return { url: String(url), source: 'vkeys-v3' }
}
}
} catch {
// v3 失败后继续尝试 v2。
}
}
if (!excludedSources.has('vkeys')) {
for (const candidateQuality of qualityCandidates) {
const idParam = getVkeysIdParam('tencent', musicId)
const vkeysUrl = `https://api.vkeys.cn/v2/music/tencent?${idParam.key}=${encodeURIComponent(idParam.value)}&quality=${candidateQuality}`
try {
const vkeysResp = await fetch(vkeysUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
signal: AbortSignal.timeout(5000)
})
if (vkeysResp.ok) {
const data = await vkeysResp.json()
if (data.code === 200 && data.data && data.data.url) {
let url = data.data.url
if (url.startsWith('http://')) {
url = url.replace('http://', 'https://')
}
if (isKnownInvalidQqAudioUrl(url)) {
continue
}
rememberMusicUrlSource(url, 'vkeys')
return {
url,
source: 'vkeys'
}
}
}
} catch {
// 该音质请求失败,继续尝试下一个音质候选
}
}
}
// 非 VIP 登录态:官方链路作为最后兜底
if (qqMusicCookie && !isQqVip && !excludedSources.has('qq-official')) {
try {
return await requestBackendResolve('official-first')
} catch (error: any) {
console.warn('[musicUrl] QQ 官方链路兜底失败:', error?.message || error)
}
}
throw new Error('QQ音乐播放链接解析失败')
}
const { getSongUrl } = useMusicSources()
const isNeteasePlatform = platform === 'netease' || platform === 'netease-podcast'
const hasNeteaseLogin =
isNeteasePlatform &&
typeof window !== 'undefined' &&
!!window.localStorage.getItem('netease_cookie')
// 仅 VIP 登录态才优先走网易官方链路,非 VIP/未登录时 ChKSz 最优先
const isNeteaseVip =
isNeteasePlatform &&
typeof window !== 'undefined' &&
window.localStorage.getItem('netease_vip') === '1'
const neteaseOfficialFirst = hasNeteaseLogin && isNeteaseVip
let finalMusicId = musicId
let bilibiliCid: string | undefined
if (platform === 'bilibili') {
const parsed = parseBilibiliId(musicId)
finalMusicId = parsed.bvid
bilibiliCid = parsed.cid
}
const extendedOptions = {
...options,
bilibiliCid,
excludeSources: options?.excludeSources
}
// 非 VIP 登录态/未登录:ChKSz 音源最优先
if (isNeteasePlatform && !neteaseOfficialFirst) {
const chkszResult = await tryChksz(quality)
if (chkszResult) {
return chkszResult
}
}
// 先使用统一组件的音源选择逻辑
const backupResult = await getSongUrl(finalMusicId, quality, platform, undefined, extendedOptions)
if (backupResult.success && backupResult.url) {
rememberMusicUrlSource(backupResult.url, backupResult.source || 'music-source')
return {
url: backupResult.url,
source: backupResult.source || 'music-source'
}
}
// VIP 登录态官方链路失败:ChKSz 作为次优先
if (isNeteasePlatform && neteaseOfficialFirst) {
const chkszResult = await tryChksz(quality)
if (chkszResult) {
return chkszResult
}
}
// 如果是 Bilibili 平台,且 getSongUrl 失败,则直接抛出错误
if (platform === 'bilibili') {
throw new Error(backupResult.error || '获取哔哩哔哩播放链接失败')
}
// 回退到 vkeys
const normalizedQuality = Number(quality)
const qualityCandidates = isNeteasePlatform
? !hasNeteaseLogin && normalizedQuality > 4
? [...new Set([normalizedQuality, 4])]
: [Number.isNaN(normalizedQuality) ? 0 : normalizedQuality]
: [Number.isNaN(normalizedQuality) ? 8 : normalizedQuality]
const endpoint =
platform === 'netease' || platform === 'netease-podcast'
? 'netease'
: platform === 'tencent'
? 'tencent'
: platform === 'migu'
? 'migu'
: null
if (!endpoint) {
throw new Error('不支持的音乐平台')
}
// 咪咕音乐特殊处理
if (platform === 'migu') {
// 服务器位于海外时直接使用星海音源(咪咕官方接口屏蔽海外 IP)
const { isServerInChina, checkServerLocation } = useMusicSources()
if (isServerInChina.value === null) {
await checkServerLocation()
}
if (isServerInChina.value === false) {
const xinghaiUrl = await fetchXinghaiMiguUrl(String(musicId), options?.musicInfo, quality)
if (xinghaiUrl) {
rememberMusicUrlSource(xinghaiUrl, 'xinghai')
return {
url: xinghaiUrl,
source: 'xinghai'
}
}
throw new Error('咪咕音乐播放链接获取失败(星海音源不可用)')
}
try {
// 服务端以 PQ 取链后按 toneFlag 升级音质路径,客户端不再二次处理
const miguResponse: any = await $fetch('/api/native-api/migu/playurl', {
params: {
contentId: String(musicId),
toneFlag: miguQuality.flagMap[quality as number] || 'PQ'
},
timeout: 10000
})
if (miguResponse?.success && miguResponse?.url) {
let miguUrl = miguResponse.url
// 与星海分支保持一致,避免 http 链接在 https 部署下被混合内容策略拦截
if (miguUrl.startsWith('http://')) {
miguUrl = miguUrl.replace('http://', 'https://')
}
rememberMusicUrlSource(miguUrl, 'migu')
return {
url: miguUrl,
source: 'migu'
}
}
} catch (error) {
console.error('[musicUrl] 咪咕播放链接获取失败:', error)
}
// 官方接口失败时回退星海音源
const xinghaiUrl = await fetchXinghaiMiguUrl(String(musicId), options?.musicInfo, quality)
if (xinghaiUrl) {
rememberMusicUrlSource(xinghaiUrl, 'xinghai')
return {
url: xinghaiUrl,
source: 'xinghai'
}
}
throw new Error('咪咕音乐播放链接获取失败')
}
for (const candidateQuality of qualityCandidates) {
const idParam = getVkeysIdParam(endpoint as 'netease' | 'tencent', musicId)
const apiUrl = `https://api.vkeys.cn/v2/music/${endpoint}?${idParam.key}=${encodeURIComponent(idParam.value)}&quality=${candidateQuality}`
const response = await fetch(apiUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
},
signal: AbortSignal.timeout(5000)
})
if (!response.ok) {
continue
}
const data = await response.json()
if (data.code === 200 && data.data && data.data.url) {
// 将HTTP URL改为HTTPS
let url = data.data.url
if (url.startsWith('http://')) {
url = url.replace('http://', 'https://')
}
if (endpoint === 'tencent' && isKnownInvalidQqAudioUrl(url)) {
continue
}
rememberMusicUrlSource(url, 'vkeys')
return {
url,
source: 'vkeys'
}
}
}
// vkeys API返回了响应但没有有效的播放链接
throw new Error('vkeys API返回的播放链接无效')
}
export async function getMusicUrl(
platform: string,
musicId: string | number,
playUrl?: string,
options?: MusicUrlResolveOptions
): Promise<string | null> {
const result = await getMusicUrlResult(platform, musicId, playUrl, options)
return result.url
}
+92
View File
@@ -0,0 +1,92 @@
import { getSyncedTimestamp } from '~/composables/useSyncedTime'
const BASE_URL = '/api/api-enhanced/netease'
export function normalizeNeteaseResponse(data) {
if (!data || typeof data !== 'object') {
return {
code: undefined,
message: '',
body: undefined
}
}
// 已是标准响应结构时直接返回
if (data.code === 200 && data.data) {
return {
code: data.code,
message: data.message || '',
body: data.data // 统一映射到 body
}
}
const body =
Object.prototype.hasOwnProperty.call(data, 'body') && data.body && typeof data.body === 'object'
? data.body
: data
const code = typeof body.code === 'number' ? body.code : undefined
const message = typeof body.message === 'string' ? body.message : ''
return {
code,
message,
body
}
}
export async function fetchNetease(endpoint, params = {}, cookie) {
const query = new URLSearchParams()
for (const key in params) {
if (params[key] !== undefined && params[key] !== null) {
query.append(key, params[key])
}
}
if (cookie) {
query.append('cookie', cookie)
}
query.append('timestamp', getSyncedTimestamp().toString())
const url = `${BASE_URL}${endpoint}?${query.toString()}`
const response = await fetch(url)
const raw = await response.json()
return normalizeNeteaseResponse(raw)
}
export async function getUserPlaylists(uid, cookie) {
return fetchNetease('/user/playlist', { uid, limit: 100 }, cookie)
}
export async function createPlaylist(name, isPrivate, cookie) {
const params = { name }
if (isPrivate) {
params.privacy = 10
}
return fetchNetease('/playlist/create', params, cookie)
}
export async function deletePlaylist(id, cookie) {
return fetchNetease('/playlist/delete', { id }, cookie)
}
export async function addSongsToPlaylist(pid, tracks, cookie) {
return fetchNetease('/playlist/tracks', { op: 'add', pid, tracks: tracks.join(',') }, cookie)
}
export async function getPlaylistDetail(id, cookie) {
return fetchNetease('/playlist/detail', { id }, cookie)
}
export async function getPlaylistTracks(id, limit = 1000, offset = 0, cookie) {
return fetchNetease('/playlist/track/all', { id, limit, offset }, cookie)
}
export async function getRecentSongs(limit = 100, cookie) {
return fetchNetease('/record/recent/song', { limit }, cookie)
}
export async function getLoginStatus(cookie) {
return fetchNetease('/login/status', {}, cookie)
}
export async function scrobbleSong(params, cookie) {
return fetchNetease('/scrobble', params, cookie)
}
+36
View File
@@ -0,0 +1,36 @@
import { getPasswordPolicyViolation } from './password-policy'
// OAuth 注册用户名规则
export const OAUTH_REGISTER_USERNAME_PATTERN = /^[a-zA-Z0-9_-]+$/
export const OAUTH_REGISTER_USERNAME_MIN_LENGTH = 3
export const OAUTH_REGISTER_USERNAME_MAX_LENGTH = 30
// 校验 OAuth 注册凭据,返回稳定错误码与中文兜底文案;通过时返回 null。
// 不依赖 useLocale,前端与服务端可安全共用;文案本地化由调用方按 code 完成。
export const validateOAuthRegisterCredentials = (
username: string,
password: string,
confirmPassword: string
): { code: string; message: string } | null => {
if (
username.length < OAUTH_REGISTER_USERNAME_MIN_LENGTH ||
username.length > OAUTH_REGISTER_USERNAME_MAX_LENGTH
) {
return { code: 'AUTH_USERNAME_LENGTH_INVALID', message: '用户名长度需要在3-30个字符之间' }
}
if (!OAUTH_REGISTER_USERNAME_PATTERN.test(username)) {
return { code: 'AUTH_USERNAME_PATTERN_INVALID', message: '用户名仅可包含英文、数字、下划线和连字符' }
}
const passwordViolation = getPasswordPolicyViolation(password)
if (passwordViolation) {
return passwordViolation
}
if (password !== confirmPassword) {
return { code: 'AUTH_REGISTER_PASSWORD_MISMATCH', message: '两次输入的密码不一致' }
}
return null
}
+107
View File
@@ -0,0 +1,107 @@
import { useLocale } from '~/utils/locale'
export const AGGREGATE_OAUTH_LOGIN_TYPE_OPTIONS = [
{ value: 'qq', label: 'QQ' },
{ value: 'wx', label: 'WeChat' },
{ value: 'alipay', label: 'Alipay' },
{ value: 'sina', label: 'Weibo' },
{ value: 'baidu', label: 'Baidu' },
{ value: 'douyin', label: 'Douyin' },
{ value: 'huawei', label: 'Huawei' },
{ value: 'xiaomi', label: 'Xiaomi' },
{ value: 'gitee', label: 'Gitee' },
{ value: 'gitea', label: 'Gitea' },
{ value: 'bilibili', label: 'Bilibili' },
{ value: 'kuaishou', label: 'Kuaishou' }
] as const
const AGGREGATE_OAUTH_LOGIN_TYPE_ICONS: Record<string, string> = {
qq: 'oauth-qq',
wx: 'oauth-wechat',
alipay: 'oauth-alipay',
sina: 'oauth-sina-weibo',
baidu: 'oauth-baidu',
douyin: 'oauth-douyin',
huawei: 'oauth-huawei',
xiaomi: 'oauth-xiaomi',
gitee: 'oauth-gitee',
gitea: 'oauth-gitea',
bilibili: 'oauth-bilibili',
kuaishou: 'oauth-kuaishou'
}
export const normalizeAggregateOAuthLoginTypes = (value: unknown): string[] => {
let values: unknown[] = []
if (Array.isArray(value)) {
values = value
} else if (typeof value === 'string' && value.trim()) {
const normalized = value.trim()
try {
const parsed = JSON.parse(normalized)
values = Array.isArray(parsed) ? parsed : [parsed]
} catch {
values = normalized.split(',')
}
}
const supported = new Set<string>(AGGREGATE_OAUTH_LOGIN_TYPE_OPTIONS.map((item) => item.value))
return [
...new Set(
values
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim().toLowerCase())
.filter((item) => supported.has(item))
)
]
}
export const getAggregateOAuthLoginTypesOrDefault = (value: unknown): string[] => {
const normalized = normalizeAggregateOAuthLoginTypes(value)
const hasConfiguredValue =
(Array.isArray(value) && value.length > 0) ||
(typeof value === 'string' && value.trim().length > 0)
return normalized.length > 0 || hasConfiguredValue ? normalized : ['qq']
}
export const getAggregateOAuthLoginTypeName = (loginType: string): string => {
const { auth } = useLocale()
const localizedName = auth.value?.oauthButtons?.aggregateLoginTypes?.[loginType]
return (
localizedName ||
AGGREGATE_OAUTH_LOGIN_TYPE_OPTIONS.find((item) => item.value === loginType)?.label ||
loginType.toUpperCase()
)
}
export const getAggregateOAuthLoginTypeIcon = (loginType: string): string => {
return AGGREGATE_OAUTH_LOGIN_TYPE_ICONS[loginType] || 'user'
}
export const getProviderDisplayName = (provider: string): string => {
const { auth } = useLocale()
const normalizedProvider = provider.toLowerCase()
if (normalizedProvider.startsWith('aggregate:')) {
const loginType = normalizedProvider.slice('aggregate:'.length)
const providerName = getAggregateOAuthLoginTypeName(loginType)
const formatter = auth.value?.oauthButtons?.aggregateProviderName
return typeof formatter === 'function' ? formatter(providerName) : `${providerName} 登录`
}
const map: Record<string, string> = {
github: 'GitHub',
casdoor: 'Casdoor',
google: 'Google',
oauth2: auth.value?.oauthButtons?.customOAuthProvider || '第三方 OAuth',
aggregate: auth.value?.oauthButtons?.aggregateOAuthProvider || '聚合登录'
}
return map[normalizedProvider] || provider.charAt(0).toUpperCase() + provider.slice(1)
}
export const getOAuthProviderName = (provider: string): string => {
const normalizedProvider = String(provider || '').trim().toLowerCase()
if (normalizedProvider.startsWith('aggregate:')) {
return getAggregateOAuthLoginTypeName(normalizedProvider.slice('aggregate:'.length))
}
return getProviderDisplayName(normalizedProvider)
}
+101
View File
@@ -0,0 +1,101 @@
const COMMON_PASSWORDS = new Set([
'12345678',
'password',
'password123',
'qwertyui',
'qwerty123',
'admin123',
'admin123456',
'11111111',
'00000000',
'abc12345',
'iloveyou',
'letmein',
'welcome',
'voicehub'
])
export const PASSWORD_MIN_LENGTH = 6
export const PASSWORD_MAX_LENGTH = 128
export const INITIAL_PASSWORD_MIN_CLASSES = 2
export interface PasswordPolicyOptions {
minClasses?: number
complexityCode?: string
}
function getUtf8ByteLength(value: string): number {
return new TextEncoder().encode(value).length
}
const INITIAL_PASSWORD_POLICY_OPTIONS: PasswordPolicyOptions = {
minClasses: INITIAL_PASSWORD_MIN_CLASSES,
complexityCode: 'AUTH_INITIAL_PASSWORD_COMPLEXITY_REQUIRED'
}
export function getPasswordPolicyViolation(password: unknown, options: PasswordPolicyOptions = {}) {
const minClasses = options.minClasses ?? 3
const complexityCode = options.complexityCode ?? 'AUTH_PASSWORD_COMPLEXITY_REQUIRED'
if (typeof password !== 'string' || !password) {
return { code: 'AUTH_NEW_PASSWORD_REQUIRED', message: '密码不能为空' }
}
if (password.length < PASSWORD_MIN_LENGTH) {
return {
code: 'AUTH_PASSWORD_TOO_SHORT',
message: `密码长度至少为${PASSWORD_MIN_LENGTH}位`
}
}
if (password.length > PASSWORD_MAX_LENGTH) {
return {
code: 'AUTH_PASSWORD_TOO_LONG',
message: `密码长度不能超过${PASSWORD_MAX_LENGTH}位`
}
}
// bcrypt 只处理前 72 字节,拒绝超出范围的输入,避免用户以为完整密码参与校验。
if (getUtf8ByteLength(password) > 72) {
return {
code: 'AUTH_PASSWORD_TOO_MANY_BYTES',
message: '密码有效长度不能超过72字节'
}
}
const normalized = password.toLowerCase().replace(/\s+/g, '')
if (COMMON_PASSWORDS.has(normalized)) {
return {
code: 'AUTH_PASSWORD_TOO_COMMON',
message: '该密码过于常见,请更换更安全的密码'
}
}
const categories = [
/[a-z]/.test(password),
/[A-Z]/.test(password),
/[0-9]/.test(password),
/[^A-Za-z0-9]/.test(password)
].filter(Boolean).length
if (categories < minClasses) {
return {
code: complexityCode,
message: `密码至少需要包含大写字母、小写字母、数字、特殊字符中的${minClasses === 2 ? '两' : '三'}类`
}
}
return null
}
export function getInitialPasswordPolicyViolation(password: unknown) {
return getPasswordPolicyViolation(password, INITIAL_PASSWORD_POLICY_OPTIONS)
}
export function validatePasswordPolicy(password: unknown): string | null {
return getPasswordPolicyViolation(password)?.message ?? null
}
export function validateInitialPasswordPolicy(password: unknown): string | null {
return getInitialPasswordPolicyViolation(password)?.message ?? null
}
+44
View File
@@ -0,0 +1,44 @@
/**
* 前端平台元数据共享模块(单一来源)
* 平台白名单、显示名国际化键、显示名回退值、图标字符
* 新增平台需同步:本文件 + server/config/constants.ts 的 MUSIC_SOURCE_PLATFORMS + app/drizzle/schema.ts 默认值与迁移文件
*/
export const DEFAULT_PLATFORMS = ['netease', 'tencent', 'bilibili', 'migu'] as const
// 平台显示名键 → 国际化文案键(按 siteConfig 段)
export const PLATFORM_NAME_KEYS = {
netease: 'platformNetease',
tencent: 'platformTencent',
bilibili: 'platformBilibili',
migu: 'platformMigu'
} as const
// 平台显示名回退值(词典缺失时使用)
export const PLATFORM_NAME_FALLBACK = {
netease: '网易云音乐',
tencent: 'QQ音乐',
bilibili: '哔哩哔哩',
migu: '咪咕音乐'
} as const
export const PLATFORM_NAME_FALLBACK_EN = {
netease: 'NetEase Cloud Music',
tencent: 'QQ Music',
bilibili: 'Bilibili',
migu: 'Migu Music'
} as const
/**
* 获取平台本地化显示名(优先 siteConfig 词典,回退到硬编码值)
*/
export const getPlatformDisplayName = (key: string, siteConfig: any, currentLocale: string): string => {
const nameKey = PLATFORM_NAME_KEYS[key as keyof typeof PLATFORM_NAME_KEYS]
if (nameKey && siteConfig?.[nameKey]) {
return siteConfig[nameKey]
}
const isEn = currentLocale === 'en-US'
return isEn
? (PLATFORM_NAME_FALLBACK_EN[key as keyof typeof PLATFORM_NAME_FALLBACK_EN] ?? key)
: (PLATFORM_NAME_FALLBACK[key as keyof typeof PLATFORM_NAME_FALLBACK] ?? key)
}
+80
View File
@@ -0,0 +1,80 @@
// QQ 音乐用户资料库(歌单)前端封装
const BASE_URL = '/api/native-api/qq'
// 归一化为弹窗通用曲目形状 {id, name, ar[], al, dt}
export const normalizeQqLibrarySong = (raw) => {
// 兼容字符串数组与对象数组两种歌手形态
const singerSource = Array.isArray(raw?.singer)
? raw.singer
: Array.isArray(raw?.singers)
? raw.singers
: []
const ar = singerSource
.map((item) =>
typeof item === 'string' ? { name: item } : { name: String(item?.name || '') }
)
.filter((a) => a.name)
const name = String(raw?.songname ?? raw?.name ?? raw?.title ?? '').trim()
if (!name) return null
const mid = String(raw?.songmid ?? raw?.mid ?? '')
const albumMid = String(
raw?.albumMid ?? raw?.albummid ?? raw?.album?.mid ?? ''
)
const albumName = String(
raw?.albumName ?? raw?.albumname ?? raw?.album?.name ?? raw?.album?.title ?? ''
).trim()
const interval = Number(raw?.interval) || 0
return {
id: mid,
name,
ar,
al: {
name: albumName,
id: albumMid || undefined,
picUrl: albumMid
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
: ''
},
// 投稿链路对非网易平台按时长秒处理
dt: interval
}
}
const postLibrary = async (endpoint, payload) => {
const res = await $fetch(`${BASE_URL}${endpoint}`, {
method: 'POST',
body: payload
})
return res?.data || {}
}
export async function getQqUserPlaylists(cookie) {
const data = await postLibrary('/playlists', { cookie })
return Array.isArray(data.playlists) ? data.playlists : []
}
// QQ 音乐"我喜欢"虚拟歌单 id,playlist-songs 接口按此标志走专用链路
export const QQ_FAV_PLAYLIST_ID = '__qq_fav_songs__'
export async function getQqPlaylistSongs(disstid, cookie, limit = 100, offset = 0) {
const isFav = disstid === QQ_FAV_PLAYLIST_ID
const data = await postLibrary('/playlist-songs', {
disstid: isFav ? '' : disstid,
favSongs: isFav,
cookie,
limit,
offset
})
const songs = (Array.isArray(data.songs) ? data.songs : [])
.map(normalizeQqLibrarySong)
.filter(Boolean)
return {
playlist: data.playlist || null,
songs,
total: Number(data.total) || songs.length,
hasMore: offset + songs.length < (Number(data.total) || songs.length)
}
}
+90
View File
@@ -0,0 +1,90 @@
const MAX_ERROR_CAUSE_DEPTH = 3
export const EXPECTED_UPSTREAM_MUSIC_ERROR_PATTERNS = [
'QQ 音乐播放链接解析失败:',
'返回已知无效音频链接',
'qq-music-api 未返回歌词',
'[tx.lyric] qq-music-api 歌词接口失败',
'The operation was aborted due to timeout',
'request timed out',
'timeout exceeded',
'TimeoutError'
]
type SentryLikeEvent = {
message?: unknown
exception?: {
values?: Array<{
type?: unknown
value?: unknown
}>
}
logentry?: {
message?: unknown
params?: unknown
}
}
type SentryLikeHint = {
originalException?: unknown
syntheticException?: unknown
}
export const stringifyErrorValue = (
value: unknown,
depth = 0,
seen = new WeakSet<object>()
): string => {
if (!value || depth > MAX_ERROR_CAUSE_DEPTH) return ''
if (typeof value === 'string') return value
if (typeof value !== 'object') return String(value)
if (seen.has(value)) return ''
seen.add(value)
const record = value as Record<string, unknown>
const errorName = value instanceof Error ? value.name : record.name
const errorMessage = value instanceof Error ? value.message : record.message
return [
errorName,
errorMessage,
record.statusMessage,
record.statusCode,
stringifyErrorValue(record.cause, depth + 1, seen)
]
.filter((item) => item !== undefined && item !== null && item !== '')
.map(String)
.join(' ')
}
export const getSentryEventSearchText = (
event: SentryLikeEvent,
hint?: SentryLikeHint
): string => {
const exceptionValues = event.exception?.values || []
const exceptionTexts = exceptionValues.flatMap((value) => [
value.type,
value.value
])
const logEntry = event.logentry || {}
return [
event.message,
logEntry.message,
...(Array.isArray(logEntry.params) ? logEntry.params : []),
...exceptionTexts,
stringifyErrorValue(hint?.originalException),
stringifyErrorValue(hint?.syntheticException)
]
.filter((item) => item !== undefined && item !== null && item !== '')
.map(String)
.join('\n')
}
export const isExpectedUpstreamMusicError = (text: string): boolean => {
const normalizedText = text.toLowerCase()
return EXPECTED_UPSTREAM_MUSIC_ERROR_PATTERNS.some((pattern) =>
normalizedText.includes(pattern.toLowerCase())
)
}
+26
View File
@@ -0,0 +1,26 @@
import { TRAD_TO_SIMP } from './data/cjkT2sMap.ts'
/** 由映射表键构建繁体字符类(u 标志保证扩展区码点按整字匹配) */
const TRAD_CHARS = Object.keys(TRAD_TO_SIMP)
.map((ch) => (ch === '\\' || ch === ']' || ch === '-' ? `\\${ch}` : ch))
.join('')
const TRAD_RE = new RegExp(`[${TRAD_CHARS}]`, 'gu')
/** 繁体转简体(仅单字映射,用于匹配归一化,不做词汇级转换) */
export function toSimplifiedChinese(str: string): string {
if (!str) return str
return str.replace(TRAD_RE, (ch) => TRAD_TO_SIMP[ch] ?? ch)
}
/**
* 歌曲名/歌手名匹配归一化(前后端唯一权威实现)
* 口径:繁→简 → 小写 → 移除 feat./ft. → &/& → and → 移除空白与全半角标点
*/
export function normalizeForMatch(str: string): string {
return toSimplifiedChinese(String(str ?? ''))
.toLowerCase()
.replace(/\b(feat\.?|ft\.?)\b/gi, '')
.replace(/[&&]/g, 'and')
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉"'“”‘’、,。!?:;~·]/g, '')
.trim()
}
+188
View File
@@ -0,0 +1,188 @@
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
import timezone from 'dayjs/plugin/timezone.js'
dayjs.extend(utc)
dayjs.extend(timezone)
const BEIJING_TIMEZONE = 'Asia/Shanghai'
/**
* 时间工具函数
* 使用 dayjs 处理北京时间(UTC+8)相关逻辑
*
* 核心原则:
* 1. 系统内部传递的 Date 对象保持为真实的 UTC 时间。
* 2. 数据库中存储的 Timestamp 字段应为真实 UTC 时间。
* 3. 只有在需要"显示"或"按北京时间逻辑计算(如每日限额)"时,才转换为北京时间处理。
* 4. 所有"当前时间"均使用同步后的时间(服务端用 getServerTimestamp,客户端用 getSyncedTimestamp)。
*/
/** 获取同步后的当前时间戳 (毫秒) */
function getSyncedNow(): number {
if (import.meta.server) {
return (globalThis as any).getServerTimestamp?.() ?? Date.now()
}
return (globalThis as any).getSyncedTimestamp?.() ?? Date.now()
}
/**
* 获取当前时间 (真实 UTC)
* 使用同步后的时间
*/
export function getBeijingTime(): Date {
return new Date(getSyncedNow())
}
/**
* 转换时间 (保留函数签名以兼容旧代码)
*/
export function toBeijingTime(date: Date): Date {
return date
}
/**
* 创建北京时间对应的 UTC Date
*/
export function createBeijingTime(
year: number,
month: number,
day: number,
hour: number = 0,
minute: number = 0,
second: number = 0
): Date {
return dayjs()
.tz(BEIJING_TIMEZONE)
.set('year', year)
.set('month', month)
.set('date', day)
.set('hour', hour)
.set('minute', minute)
.set('second', second)
.set('millisecond', 0)
.toDate()
}
/**
* 格式化时间为北京时间字符串
*/
export function formatDateTime(date: Date, format: string = 'YYYY/M/D H:mm:ss'): string {
return dayjs(date).tz(BEIJING_TIMEZONE).format(format)
}
/**
* 获取时间戳 (Date 对象)
*/
export function getBeijingTimestamp(): Date {
return new Date(getSyncedNow())
}
/**
* 解析北京时间字符串为真实 UTC Date
*/
export function parseToBeijingTime(dateString: string): Date {
return dayjs.tz(dateString, BEIJING_TIMEZONE).toDate()
}
/**
* 获取当前北京时间的 ISO 字符串
*/
export function getBeijingTimeISOString(): string {
return dayjs(getSyncedNow()).tz(BEIJING_TIMEZONE).format('YYYY-MM-DDTHH:mm:ss')
}
/**
* 获取北京时间的小时 (0-23)
*/
export function getBeijingHour(date: Date): number {
return dayjs(date).tz(BEIJING_TIMEZONE).hour()
}
/**
* 获取"北京时间某天开始"对应的真实 UTC 时间
*/
export function getBeijingStartOfDay(date?: Date): Date {
const target = date ? dayjs(date).tz(BEIJING_TIMEZONE) : dayjs(getSyncedNow()).tz(BEIJING_TIMEZONE)
return target.startOf('day').toDate()
}
/**
* 获取"北京时间某天结束"对应的真实 UTC 时间
*/
export function getBeijingEndOfDay(date?: Date): Date {
const target = date ? dayjs(date).tz(BEIJING_TIMEZONE) : dayjs(getSyncedNow()).tz(BEIJING_TIMEZONE)
return target.endOf('day').toDate()
}
/**
* 获取"北京时间本周开始"对应的真实 UTC 时间 (周一 00:00:00)
*/
export function getBeijingStartOfWeek(date?: Date): Date {
const target = date ? dayjs(date).tz(BEIJING_TIMEZONE) : dayjs(getSyncedNow()).tz(BEIJING_TIMEZONE)
const day = target.day()
const diff = day === 0 ? 6 : day - 1
return target.subtract(diff, 'day').startOf('day').toDate()
}
/**
* 获取"北京时间本周结束"对应的真实 UTC 时间 (周日 23:59:59)
*/
export function getBeijingEndOfWeek(date?: Date): Date {
const startOfWeek = dayjs(getBeijingStartOfWeek(date)).tz(BEIJING_TIMEZONE)
return startOfWeek.add(6, 'day').endOf('day').toDate()
}
/**
* 获取"北京时间本月开始"对应的真实 UTC 时间
*/
export function getBeijingStartOfMonth(date?: Date): Date {
const target = date ? dayjs(date).tz(BEIJING_TIMEZONE) : dayjs(getSyncedNow()).tz(BEIJING_TIMEZONE)
return target.startOf('month').toDate()
}
/**
* 获取"北京时间本月结束"对应的真实 UTC 时间
*/
export function getBeijingEndOfMonth(date?: Date): Date {
const target = date ? dayjs(date).tz(BEIJING_TIMEZONE) : dayjs(getSyncedNow()).tz(BEIJING_TIMEZONE)
return target.endOf('month').toDate()
}
/**
* 格式化歌曲/音频时长(秒 → 分:秒 / 时:分:秒)
*/
export function formatDuration(seconds: number): string {
const total = Number(seconds)
if (!isFinite(total) || total < 0) return ''
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = Math.floor(total % 60)
const minuteText = hours > 0 ? minutes.toString().padStart(2, '0') : String(minutes)
const secondText = secs.toString().padStart(2, '0')
return hours > 0 ? `${hours}:${minuteText}:${secondText}` : `${minuteText}:${secondText}`
}
/**
* 字符串日期(YYYY-MM-DD)增加指定天数
*/
export function addDaysToString(dateStr: string, days: number): string {
const match = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(dateStr)
if (!match) return dateStr
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days))
const pad = (n: number) => (n < 10 ? '0' + n : '' + n)
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
}
/**
* 计算两个字符串日期之间的天数差(end - start)
* 负数表示 start 晚于 end
*/
export function getDaysBetween(startStr: string, endStr: string): number {
const startMatch = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(startStr)
const endMatch = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(endStr)
if (!startMatch || !endMatch) return -1
const start = new Date(Date.UTC(Number(startMatch[1]), Number(startMatch[2]) - 1, Number(startMatch[3]))).getTime()
const end = new Date(Date.UTC(Number(endMatch[1]), Number(endMatch[2]) - 1, Number(endMatch[3]))).getTime()
return Math.round((end - start) / 86400000)
}
+181
View File
@@ -0,0 +1,181 @@
/**
* 将HTTP URL转换为HTTPS
* @param url - 要转换的URL
* @returns 转换后的HTTPS URL
*/
export const convertToHttps = (url: string | null | undefined): string => {
if (!url) return url || ''
if (url.startsWith('http://')) {
return url.replace('http://', 'https://')
}
return url
}
/**
* 验证URL格式是否有效
* @param url - 要验证的URL字符串
* @returns 验证结果对象
*/
export const validateUrl = (url: string): { valid: boolean; error?: string } => {
if (!url) return { valid: true } // 空URL视为有效(可选字段)
if (typeof url !== 'string') {
return { valid: false, error: 'URL必须是字符串类型' }
}
// 检查是否以http://或https://开头
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return { valid: false, error: 'URL必须以http://或https://开头' }
}
// 检查是否包含://结构
if (!url.includes('://')) {
return { valid: false, error: 'URL格式不正确,缺少://结构' }
}
try {
// 使用URL构造函数验证基本格式
new URL(url)
return { valid: true }
} catch {
return { valid: false, error: 'URL格式不正确' }
}
}
/**
* 批量验证URL
* @param urls - URL对象数组,包含url和type字段
* @returns Promise<ValidationResult[]> - 验证结果数组
*/
export interface UrlValidationItem {
url: string
type?: 'image' | 'audio' // 保持兼容性,但不再使用
name?: string // 用于标识URL的名称
}
export interface ValidationResult {
url: string
type?: 'image' | 'audio'
name?: string
valid: boolean
error?: string
}
const normalizeBilibiliWebUrl = (value: string): string | null => {
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
if (url.username || url.password) return null
const hostname = url.hostname.toLowerCase().replace(/\.$/, '')
if (hostname !== 'bilibili.com' && !hostname.endsWith('.bilibili.com')) return null
return url.href
} catch {
return null
}
}
export const validateUrls = async (urls: UrlValidationItem[]): Promise<ValidationResult[]> => {
const results: ValidationResult[] = []
for (const item of urls) {
const result = validateUrl(item.url)
results.push({
url: item.url,
type: item.type,
name: item.name,
valid: result.valid,
error: result.error
})
}
return results
}
/**
* 获取 Bilibili 视频链接
* @param song - 歌曲对象,包含 playUrl 和 musicId
* @returns Bilibili 视频链接
*/
export const getBilibiliUrl = (song: {
playUrl?: string | null
musicId?: string | null
}): string => {
if (!song) return '#'
// 优先使用 playUrl,但必须确认它确实属于 Bilibili 域名
if (song.playUrl) {
const bilibiliUrl = normalizeBilibiliWebUrl(song.playUrl)
if (bilibiliUrl) return bilibiliUrl
}
if (song.musicId) {
// 处理带分P的情况,支持以下格式:
// 1. BVxxx (只有 BV 号) -> 跳转到 BV 视频页
// 2. BVxxx:cid (BV + CID) -> 跳转到 BV 视频页 (默认 P1)
// 3. BVxxx:cid:p (BV + CID + 分P页码) -> 跳转到指定 P
const parts = song.musicId.split(':')
const bvId = parts[0]
// 检查是否有第三个参数(分P信息)
// 如果没有冒号 (length=1) 或只有一段冒号 (length=2),则 p=1
const parsedP = parts.length >= 3 ? parseInt(parts[2]) : 1
const p = isNaN(parsedP) ? 1 : parsedP
let url = `https://www.bilibili.com/video/${bvId}/`
// 只有当 p > 1 时才添加 p 参数
if (p > 1) {
url += `?p=${p}`
}
return url
}
return '#'
}
/**
* 判断 URL 是否属于 VoiceHub 内部 API
* @param input 请求的 URL 或 Request 对象
* @returns boolean
*/
export const isVoiceHubApi = (input: RequestInfo | URL): boolean => {
try {
let urlStr = ''
if (typeof input === 'string') {
urlStr = input
} else if (input instanceof URL) {
urlStr = input.href
} else if (input instanceof Request) {
urlStr = input.url
}
// 相对路径直接判断
if (urlStr.startsWith('/api')) return true
// 如果在客户端环境中,可以检查是否属于当前域名
if (typeof window !== 'undefined') {
const urlObj = new URL(urlStr, window.location.origin)
return urlObj.origin === window.location.origin && urlObj.pathname.startsWith('/api')
}
return false
} catch {
return false
}
}
/**
* 从 localStorage 中获取网易云音乐 Cookie
* @returns {string | undefined} 返回 Cookie 字符串或 undefined
*/
export const getNeteaseCookie = (): string | undefined => {
if (import.meta.client) {
return localStorage.getItem('netease_cookie') || undefined
}
return undefined
}
+28
View File
@@ -0,0 +1,28 @@
/**
* 账号归档判定与筛选参数解析。
* 归档口径:账户状态为 graduate(限制访问-毕业生)或 withdrawn(限制访问-退学)
* 即视为已归档,归档账号不在用户管理默认列表与学段中直接展示,
* 收敛在组织树底部的归档节点中查看。
*/
export const ARCHIVED_USER_STATUSES = ['graduate', 'withdrawn'] as const
export type ArchivedFilter = 'archived' | 'unarchived' | 'all'
/**
* 判断状态是否为归档状态。
* @param status 用户状态枚举值
*/
export function isArchivedStatus(status: string | null | undefined): boolean {
return (ARCHIVED_USER_STATUSES as readonly string[]).includes(status || '')
}
/**
* 解析归档筛选参数:archived=1/true 仅查已归档;archived=0/false 排除已归档;缺省不限制。
* @param value 查询参数(可能是数组)
*/
export function resolveArchivedFilter(value: string | string[] | undefined): ArchivedFilter {
const raw = Array.isArray(value) ? value[0] : value
if (raw === '1' || raw === 'true') return 'archived'
if (raw === '0' || raw === 'false') return 'unarchived'
return 'all'
}
+227
View File
@@ -0,0 +1,227 @@
import {
base64URLStringToBuffer,
bufferToBase64URLString,
startAuthentication
} from '@simplewebauthn/browser'
const toCredentialDescriptor = ({ id, type }) => ({
id: base64URLStringToBuffer(id),
type
})
const createWebAuthnError = (code, cause) => {
const error = new Error(code)
error.code = code
error.cause = cause
return error
}
const normalizeAuthenticationError = (error) => {
switch (error?.name) {
case 'NotAllowedError':
case 'AbortError':
return createWebAuthnError('passkeyOperationUnavailable', error)
case 'NotSupportedError':
return createWebAuthnError('passkeyLoginUnsupported', error)
case 'SecurityError':
return createWebAuthnError('passkeySecurityInvalid', error)
default:
return createWebAuthnError('passkeyFailed', error)
}
}
const normalizeRegistrationError = (error) => {
let code
switch (error?.name) {
case 'NotAllowedError':
case 'AbortError':
code = 'passkeyOperationUnavailable'
break
case 'InvalidStateError':
code = 'passkeyAlreadyRegistered'
break
case 'NotSupportedError':
code = 'passkeyAlgorithmUnsupported'
break
case 'SecurityError':
code = 'passkeySecurityInvalid'
break
default:
code = 'addDeviceFailed'
}
const normalizedError = createWebAuthnError(code, error)
normalizedError.name = error.name
return normalizedError
}
export const getWebAuthnErrorMessage = (error, messages, fallback) => {
const localizedMessage = error?.code ? messages?.[error.code] : ''
return localizedMessage || error?.data?.message || error?.statusMessage || fallback
}
const readClientExtensionResults = (credential) => {
try {
return credential.getClientExtensionResults?.() || {}
} catch (error) {
// 鸿蒙早期实现可能缺少完整扩展接口,扩展结果不参与服务端签名验证。
console.warn('读取 WebAuthn 扩展结果失败:', error)
return {}
}
}
const readOptionalResponseValue = (response, methodName) => {
try {
return response[methodName]?.()
} catch (error) {
// 部分系统凭据桥仅实现必需字段,可选元数据失败不应中断认证。
console.warn(`读取 WebAuthn ${methodName} 失败:`, error)
return undefined
}
}
export const signalUnknownWebAuthnCredential = async ({ credentialId, rpId }) => {
if (typeof window === 'undefined') return false
const signalUnknownCredential = window.PublicKeyCredential?.signalUnknownCredential
if (!credentialId || !rpId || typeof signalUnknownCredential !== 'function') {
return false
}
try {
await signalUnknownCredential.call(window.PublicKeyCredential, {
credentialId,
rpId
})
return true
} catch (error) {
// 设备可能只实现基础 WebAuthn,清理失败不能影响服务端解绑结果。
console.warn('通知系统清理失效 Passkey 失败:', error)
return false
}
}
export const startWebAuthnAuthentication = async (optionsJSON, useBrowserAutofill = false) => {
if (useBrowserAutofill) {
return startAuthentication({ optionsJSON, useBrowserAutofill: true })
}
if (!navigator.credentials?.get) {
throw createWebAuthnError('passkeyLoginUnsupported')
}
const { challenge, allowCredentials, ...remainingOptions } = optionsJSON
const publicKey = {
...remainingOptions,
challenge: base64URLStringToBuffer(challenge)
}
if (allowCredentials?.length) {
publicKey.allowCredentials = allowCredentials.map(toCredentialDescriptor)
}
// 部分鸿蒙版本的 WebAuthn 桥接会拒绝 AbortSignal,登录按钮本身已阻止重复提交。
let credential
try {
credential = await navigator.credentials.get({ publicKey })
} catch (error) {
throw normalizeAuthenticationError(error)
}
if (!credential?.response) {
throw createWebAuthnError('passkeyAuthenticationIncomplete')
}
const response = credential.response
return {
id: credential.id,
rawId: bufferToBase64URLString(credential.rawId),
response: {
authenticatorData: bufferToBase64URLString(response.authenticatorData),
clientDataJSON: bufferToBase64URLString(response.clientDataJSON),
signature: bufferToBase64URLString(response.signature),
userHandle: response.userHandle ? bufferToBase64URLString(response.userHandle) : undefined
},
type: credential.type,
clientExtensionResults: readClientExtensionResults(credential),
authenticatorAttachment: credential.authenticatorAttachment || undefined
}
}
export const startWebAuthnRegistration = async (optionsJSON) => {
if (!navigator.credentials?.create) {
throw createWebAuthnError('passkeyRegistrationUnsupported')
}
const {
challenge,
user,
excludeCredentials,
hints,
extensions,
authenticatorSelection,
...remainingOptions
} = optionsJSON
const compatibleExtensions = { ...extensions }
const compatibleAuthenticatorSelection = { ...authenticatorSelection }
// 这些提示不参与服务端验证,部分鸿蒙凭据桥会因不认识它们而拒绝整个请求。
delete compatibleExtensions.credProps
delete compatibleAuthenticatorSelection.residentKey
const publicKey = {
...remainingOptions,
challenge: base64URLStringToBuffer(challenge),
user: {
...user,
id: base64URLStringToBuffer(user.id)
}
}
if (Object.keys(compatibleExtensions).length) {
publicKey.extensions = compatibleExtensions
}
if (Object.keys(compatibleAuthenticatorSelection).length) {
publicKey.authenticatorSelection = compatibleAuthenticatorSelection
}
if (excludeCredentials?.length) {
publicKey.excludeCredentials = excludeCredentials.map(toCredentialDescriptor)
}
if (hints?.length) {
publicKey.hints = hints
}
// 部分鸿蒙版本的 WebAuthn 桥接会拒绝 AbortSignal,绑定按钮本身已阻止重复提交。
let credential
try {
credential = await navigator.credentials.create({ publicKey })
} catch (error) {
throw normalizeRegistrationError(error)
}
if (!credential?.response) {
throw createWebAuthnError('passkeyCreationIncomplete')
}
const response = credential.response
const publicKeyAlgorithm = readOptionalResponseValue(response, 'getPublicKeyAlgorithm')
const credentialPublicKey = readOptionalResponseValue(response, 'getPublicKey')
const authenticatorData = readOptionalResponseValue(response, 'getAuthenticatorData')
return {
id: credential.id,
rawId: bufferToBase64URLString(credential.rawId),
response: {
attestationObject: bufferToBase64URLString(response.attestationObject),
clientDataJSON: bufferToBase64URLString(response.clientDataJSON),
transports: readOptionalResponseValue(response, 'getTransports'),
publicKeyAlgorithm,
publicKey: credentialPublicKey ? bufferToBase64URLString(credentialPublicKey) : undefined,
authenticatorData: authenticatorData ? bufferToBase64URLString(authenticatorData) : undefined
},
type: credential.type,
clientExtensionResults: readClientExtensionResults(credential),
authenticatorAttachment: credential.authenticatorAttachment || undefined
}
}