feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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}",
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 = [
|
||||
'(?:【.*?音乐人.*?】|\\(.*?音乐人.*?\\)|「.*?音乐人.*?」|(.*?音乐人.*?)|『.*?音乐人.*?』)',
|
||||
'.*?未经.*?不得.*?'
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user