- adjustRange 改变范围时同时清空短语链接与已加载条目:调整端点后不再声称「已保存」, 已输入文本保留,保存按当前范围确定的身份写入(可能更新另一条,但绝不重复) - 新增 TestWordKeysNeverContainSpaces:显式锁定「词片段不含空白」这一不变量, termKind/termWordCount 完全依赖它,此前只有间接覆盖 - e2e 断言超时提高到 15s:11 个用例共用开发服务器并行运行时,首屏模块加载可能超过 默认 5s 造成偶发失败(产品行为未变,连续两次并行全量运行均通过)
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
|
|
import type { ChapterDetail } from '../stores/library'
|
|
import { useSessionStore } from '../stores/session'
|
|
import { adjustTokenRange, type TokenRange } from './readerRange'
|
|
|
|
export type TermStatus = 'new' | 'learning' | 'known' | 'ignored'
|
|
|
|
export interface TokenTerm { id: number; status: TermStatus; level: number }
|
|
export interface ReaderToken {
|
|
text: string
|
|
start: number
|
|
end: number
|
|
startUtf16: number
|
|
endUtf16: number
|
|
kind: 'word' | 'space' | 'punctuation'
|
|
term?: TokenTerm | null
|
|
}
|
|
/** One saved phrase occurrence inside the chapter, in token indices. */
|
|
export interface PhraseSpan { id: number; status: TermStatus; wordCount: number; startToken: number; endToken: number }
|
|
export interface LookupResult {
|
|
status: 'exact' | 'lemma' | 'not_found' | 'resource_missing'
|
|
query: string
|
|
matchedForm: string | null
|
|
candidates: string[]
|
|
entries: { lemma: string; pos: string; definition: string; examples: string[] }[]
|
|
resource?: { name: string; version: string }
|
|
}
|
|
export interface SavedTerm {
|
|
id: number
|
|
term: string
|
|
originalForm: string
|
|
definition: string
|
|
examples: string[]
|
|
status: TermStatus
|
|
level: number
|
|
kind: 'word' | 'phrase'
|
|
wordCount: number
|
|
}
|
|
interface TermResponse { term: SavedTerm }
|
|
interface TokenResponse { textSha256: string; tokens: ReaderToken[]; phrases?: PhraseSpan[] }
|
|
|
|
// The four learner-visible states and the level rule behind them. Only a learning
|
|
// entry carries a level, so every other status reports level 0.
|
|
export const TERM_STATUSES: { value: TermStatus; label: string }[] = [
|
|
{ value: 'new', label: '新词' },
|
|
{ value: 'learning', label: '学习中' },
|
|
{ value: 'known', label: '已知' },
|
|
{ value: 'ignored', label: '忽略' },
|
|
]
|
|
|
|
export function termStatusLabel(status: TermStatus): string {
|
|
return TERM_STATUSES.find(item => item.value === status)?.label ?? status
|
|
}
|
|
|
|
/** Unknown or malformed personal state never becomes a highlight class. */
|
|
export function termStatusOf(token: ReaderToken): TermStatus | null {
|
|
const term = token.term
|
|
if (!term || typeof term.id !== 'number' || term.id <= 0) return null
|
|
return TERM_STATUSES.some(item => item.value === term.status) ? term.status : null
|
|
}
|
|
|
|
// Validate every coordinate before enabling selection; original text is always the fallback.
|
|
function matchesChapter(data: TokenResponse, chapter: ChapterDetail): boolean {
|
|
if (!data || data.textSha256 !== chapter.contentSha256 || !Array.isArray(data.tokens)) return false
|
|
const text = chapter.originalText ?? ''
|
|
let cp = 0, utf16 = 0
|
|
for (const token of data.tokens) {
|
|
if (typeof token.text !== 'string' || !token.text || !['word', 'space', 'punctuation'].includes(token.kind)) return false
|
|
if (token.start !== cp || token.startUtf16 !== utf16) return false
|
|
cp += [...token.text].length
|
|
utf16 += token.text.length
|
|
if (token.end !== cp || token.endUtf16 !== utf16 || text.slice(token.startUtf16, utf16) !== token.text) return false
|
|
}
|
|
return utf16 === text.length && data.tokens.map(token => token.text).join('') === text
|
|
}
|
|
|
|
export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
|
|
const session = useSessionStore()
|
|
const tokens = ref<ReaderToken[]>([])
|
|
const phrases = ref<PhraseSpan[]>([])
|
|
const tokensError = ref('')
|
|
const tokensLoading = ref(false)
|
|
const selected = ref<ReaderToken | null>(null)
|
|
// A phrase selection: either a drag over several words or a click on a saved phrase span.
|
|
const range = ref<TokenRange | null>(null)
|
|
const rangeTermId = ref<number | null>(null)
|
|
const result = ref<LookupResult | null>(null)
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
|
|
// The learner's own record for the selected word.
|
|
const definition = ref('')
|
|
const examples = ref('')
|
|
const status = ref<TermStatus>('new')
|
|
const savedTermId = ref<number | null>(null)
|
|
const prefilling = ref(false)
|
|
const prefillError = ref('')
|
|
const saving = ref(false)
|
|
const saveError = ref('')
|
|
const saved = ref('')
|
|
|
|
let tokenSequence = 0, lookupSequence = 0, termSequence = 0, saveSequence = 0
|
|
let origin: HTMLElement | null = null
|
|
let scrollBeforeAdjustment: { top: number; left: number } | null = null
|
|
let lastAdjustment: { top: number; left: number } | null = null
|
|
|
|
// Sending is blocked until the stored text is on screen, so a slow or failed read
|
|
// can never let an empty form overwrite what the learner already wrote.
|
|
const canSave = computed(() => (selected.value !== null || range.value !== null) && !saving.value && !prefilling.value && !prefillError.value)
|
|
|
|
function exampleLines(): string[] {
|
|
return examples.value.split('\n').map(line => line.trim()).filter(Boolean)
|
|
}
|
|
|
|
function applyTerm(next: TokenTerm) {
|
|
savedTermId.value = next.id
|
|
const index = tokens.value.findIndex(token => token.start === selected.value?.start)
|
|
const target = index >= 0 ? tokens.value[index] : undefined
|
|
if (target) tokens.value[index] = { ...target, term: next }
|
|
}
|
|
|
|
function stillAtAdjustment() {
|
|
return lastAdjustment !== null && Math.abs(window.scrollY - lastAdjustment.top) <= 2
|
|
&& Math.abs(window.scrollX - lastAdjustment.left) <= 2
|
|
}
|
|
|
|
function keepSelectionVisible(panelTop: number) {
|
|
if (!origin) return
|
|
const coveredBy = origin.getBoundingClientRect().bottom - panelTop + 16
|
|
if (coveredBy <= 0) return
|
|
// A later manual scroll becomes the new reading position, including when
|
|
// another resize subsequently needs to reveal the selected word again.
|
|
if (!scrollBeforeAdjustment || !stillAtAdjustment()) scrollBeforeAdjustment = { top: window.scrollY, left: window.scrollX }
|
|
window.scrollBy({ top: coveredBy, behavior: 'instant' })
|
|
lastAdjustment = { top: window.scrollY, left: window.scrollX }
|
|
}
|
|
|
|
function close(restoreFocus = true) {
|
|
lookupSequence++
|
|
termSequence++
|
|
saveSequence++
|
|
selected.value = null
|
|
range.value = null
|
|
rangeTermId.value = null
|
|
result.value = null
|
|
loading.value = false
|
|
error.value = ''
|
|
definition.value = ''
|
|
examples.value = ''
|
|
status.value = 'new'
|
|
savedTermId.value = null
|
|
prefilling.value = false
|
|
prefillError.value = ''
|
|
saving.value = false
|
|
saveError.value = ''
|
|
saved.value = ''
|
|
if (restoreFocus && origin?.isConnected) origin.focus({ preventScroll: true })
|
|
if (restoreFocus && scrollBeforeAdjustment && stillAtAdjustment()) window.scrollTo({ ...scrollBeforeAdjustment, behavior: 'instant' })
|
|
scrollBeforeAdjustment = null
|
|
lastAdjustment = null
|
|
origin = null
|
|
}
|
|
function reset() {
|
|
tokenSequence++
|
|
tokens.value = []
|
|
phrases.value = []
|
|
tokensError.value = ''
|
|
tokensLoading.value = false
|
|
close(false)
|
|
}
|
|
async function loadTokens() {
|
|
const current = chapter.value
|
|
if (!session.user || current?.status !== 'ready') return
|
|
const seq = ++tokenSequence
|
|
tokensLoading.value = true
|
|
tokensError.value = ''
|
|
try {
|
|
const data = await session.request<TokenResponse>(`chapters/${current.id}/tokens`)
|
|
if (seq !== tokenSequence) return
|
|
if (!matchesChapter(data, current)) throw new Error('分词与正文不一致,请重试。')
|
|
tokens.value = data.tokens
|
|
phrases.value = Array.isArray(data.phrases) ? data.phrases : []
|
|
} catch (reason) {
|
|
if (seq !== tokenSequence) return
|
|
tokens.value = []
|
|
phrases.value = []
|
|
tokensError.value = reason instanceof Error ? reason.message : '单词暂时无法加载。'
|
|
} finally { if (seq === tokenSequence) tokensLoading.value = false }
|
|
}
|
|
async function lookup() {
|
|
const current = chapter.value
|
|
const token = selected.value
|
|
if (!session.user || !current || !token || range.value) return
|
|
const seq = ++lookupSequence
|
|
result.value = null
|
|
error.value = ''
|
|
loading.value = true
|
|
try {
|
|
const data = await session.request<LookupResult>('lookup', 'POST', { chapterId: current.id, start: token.start, end: token.end })
|
|
if (seq === lookupSequence) result.value = data
|
|
} catch (reason) {
|
|
if (seq === lookupSequence) error.value = reason instanceof Error ? reason.message : '暂时无法查询,请重试。'
|
|
} finally { if (seq === lookupSequence) loading.value = false }
|
|
}
|
|
// Opening a word the learner already saved loads that record, so the panel shows
|
|
// the stored text and the same id other chapters show.
|
|
async function loadTerm(id: number) {
|
|
const seq = ++termSequence
|
|
prefilling.value = true
|
|
prefillError.value = ''
|
|
try {
|
|
const data = await session.request<TermResponse>(`terms/${id}`)
|
|
if (seq !== termSequence) return
|
|
definition.value = data.term.definition
|
|
examples.value = data.term.examples.join('\n')
|
|
status.value = data.term.status
|
|
applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level })
|
|
} catch (reason) {
|
|
if (seq !== termSequence) return
|
|
prefillError.value = reason instanceof Error ? reason.message : '已保存的内容暂时无法读取。'
|
|
} finally { if (seq === termSequence) prefilling.value = false }
|
|
}
|
|
async function save() {
|
|
const current = chapter.value
|
|
const selection = range.value
|
|
const token = selected.value
|
|
if (!session.user || !current || (!selection && !token) || !canSave.value) return
|
|
const seq = ++saveSequence
|
|
saving.value = true
|
|
saveError.value = ''
|
|
saved.value = ''
|
|
const span = selection ?? token!
|
|
const path = selection ? 'phrases' : 'terms'
|
|
try {
|
|
const data = await session.request<TermResponse & { created: boolean }>(path, 'POST', {
|
|
chapterId: current.id, start: span.start, end: span.end,
|
|
definition: definition.value, examples: exampleLines(), status: status.value,
|
|
})
|
|
if (seq !== saveSequence) return
|
|
definition.value = data.term.definition
|
|
examples.value = data.term.examples.join('\n')
|
|
status.value = data.term.status
|
|
applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level })
|
|
if (selection) {
|
|
// The saved phrase must highlight here without waiting for another token load.
|
|
rangeTermId.value = data.term.id
|
|
const savedSpan = {
|
|
id: data.term.id, status: data.term.status, wordCount: selection.wordCount,
|
|
startToken: selection.firstWord, endToken: selection.lastWord,
|
|
}
|
|
const existing = phrases.value.findIndex(item => item.id === data.term.id)
|
|
phrases.value = existing >= 0
|
|
? phrases.value.map((item, index) => (index === existing ? savedSpan : item))
|
|
: [...phrases.value, savedSpan]
|
|
}
|
|
saved.value = `已保存 · ${termStatusLabel(data.term.status)}`
|
|
} catch (reason) {
|
|
if (seq !== saveSequence) return
|
|
saveError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
|
|
} finally { if (seq === saveSequence) saving.value = false }
|
|
}
|
|
function select(token: ReaderToken, element: HTMLElement) {
|
|
close(false)
|
|
origin = element
|
|
selected.value = token
|
|
const existing = termStatusOf(token)
|
|
if (existing !== null && token.term) {
|
|
status.value = existing
|
|
void loadTerm(token.term.id)
|
|
}
|
|
void lookup()
|
|
}
|
|
|
|
/**
|
|
* Opens the phrase form for a dragged range or for a saved phrase span. A phrase is one
|
|
* entry in the same table as a word, so an existing phrase is read through the same route.
|
|
*/
|
|
function selectRange(next: TokenRange, element: HTMLElement | null, termId: number | null = null) {
|
|
close(false)
|
|
origin = element
|
|
range.value = next
|
|
rangeTermId.value = termId
|
|
if (termId !== null) void loadTerm(termId)
|
|
}
|
|
|
|
/** Moves one end of the phrase by whole words; the panel keeps the selection in sync. */
|
|
function adjustRange(edge: 'start' | 'end', direction: -1 | 1) {
|
|
const current = range.value
|
|
const currentChapter = chapter.value
|
|
if (!current || !currentChapter) return
|
|
const next = adjustTokenRange(tokens.value, currentChapter.originalText ?? '', current, edge, direction)
|
|
if (!next) return
|
|
// The adjusted range is a different identity, so the panel must stop claiming that the
|
|
// stored entry is what is on screen: both the phrase link and the entry the form was loaded
|
|
// from are dropped. The typed text stays, and saving it writes the phrase that matches the
|
|
// current range (which may update another entry, never two).
|
|
rangeTermId.value = null
|
|
savedTermId.value = null
|
|
range.value = next
|
|
}
|
|
|
|
/** True when the panel is editing an entry that is already stored. */
|
|
const rangeStored = computed(() => rangeTermId.value !== null)
|
|
watch(() => [chapter.value?.id, chapter.value?.status, chapter.value?.contentSha256, chapter.value?.originalText], () => {
|
|
reset()
|
|
void loadTokens()
|
|
}, { immediate: true, flush: 'sync' })
|
|
// Watch the identity object, including clear → login for the same account: leaving
|
|
// or switching an account drops every word saved by the previous one.
|
|
watch(() => session.user, reset, { flush: 'sync' })
|
|
onScopeDispose(reset)
|
|
return {
|
|
tokens, phrases, tokensError, tokensLoading, selected, range, rangeTermId, rangeStored, result, loading, error,
|
|
definition, examples, status, savedTermId, prefilling, prefillError, saving, saveError, saved, canSave,
|
|
loadTokens, lookup, loadTerm, save, select, selectRange, adjustRange, close, reset, keepSelectionVisible,
|
|
}
|
|
}
|