<script lang="ts">
|
import { Call } from '@wailsio/runtime'
|
import { t } from '../../lib/i18n'
|
import CorrectionImportPreviewDialog from './CorrectionImportPreviewDialog.svelte'
|
|
interface DictEntry {
|
id: number
|
from: string
|
to: string
|
enabled: boolean
|
createdAt: number
|
updatedAt: number
|
}
|
|
interface DictPageResult {
|
entries: DictEntry[]
|
total: number
|
page: number
|
pageSize: number
|
totalPages: number
|
}
|
|
const PAGE_SIZE = 100
|
|
let entries = $state<DictEntry[]>([])
|
let page = $state(1)
|
let pageInput = $state('1')
|
let pageSize = $state(PAGE_SIZE)
|
let total = $state(0)
|
let totalPages = $state(0)
|
let pageLoaded = $state(false)
|
let isPageLoading = $state(false)
|
let fromText = $state('')
|
let toText = $state('')
|
let statusText = $state('')
|
let statusTone = $state<'success' | 'error' | 'info'>('info')
|
let jsonImportInput: HTMLInputElement
|
let editingId = $state<number | null>(null)
|
let editFrom = $state('')
|
let editTo = $state('')
|
let correctionPreview = $state<any>(null)
|
let isPreviewingCorrectionCsv = $state(false)
|
let isConfirmingCorrectionImport = $state(false)
|
let pageRequestSeq = 0
|
|
function hasPagination(): boolean {
|
return totalPages > 1
|
}
|
|
function canGoPrev(): boolean {
|
return page > 1
|
}
|
|
function canGoNext(): boolean {
|
return totalPages > 0 && page < totalPages
|
}
|
|
async function loadPage(targetPage = page) {
|
const seq = ++pageRequestSeq
|
isPageLoading = true
|
try {
|
const result: DictPageResult = await Call.ByName(
|
'voicesnap/services.UserDictService.GetPage',
|
targetPage,
|
PAGE_SIZE,
|
)
|
if (seq !== pageRequestSeq) return
|
entries = result?.entries || []
|
total = result?.total || 0
|
page = result?.page || 1
|
pageInput = String(page)
|
pageSize = result?.pageSize || PAGE_SIZE
|
totalPages = result?.totalPages || 0
|
pageLoaded = true
|
} catch {
|
entries = []
|
total = 0
|
page = 1
|
pageInput = '1'
|
pageSize = PAGE_SIZE
|
totalPages = 0
|
pageLoaded = true
|
flash(t('userdict.loadFailed'), 'error')
|
} finally {
|
if (seq === pageRequestSeq) {
|
isPageLoading = false
|
}
|
}
|
}
|
|
function flash(message: string, tone: 'success' | 'error' | 'info' = 'success') {
|
statusText = message
|
statusTone = tone
|
setTimeout(() => {
|
if (statusText === message) statusText = ''
|
}, 1800)
|
}
|
|
async function addEntry() {
|
if (!fromText.trim()) return
|
try {
|
await Call.ByName('voicesnap/services.UserDictService.Add', fromText, toText)
|
fromText = ''
|
toText = ''
|
await loadPage(1)
|
flash(t('userdict.saved'), 'success')
|
} catch {
|
flash(t('userdict.saveFailed'), 'error')
|
}
|
}
|
|
function startEdit(entry: DictEntry) {
|
editingId = entry.id
|
editFrom = entry.from
|
editTo = entry.to
|
}
|
|
function cancelEdit() {
|
editingId = null
|
editFrom = ''
|
editTo = ''
|
}
|
|
async function saveEdit(entry: DictEntry) {
|
if (!editFrom.trim()) return
|
try {
|
const updated: any = await Call.ByName(
|
'voicesnap/services.UserDictService.Update',
|
entry.id,
|
editFrom,
|
editTo,
|
entry.enabled
|
)
|
entries = entries.map(e => e.id === entry.id ? updated : e)
|
await loadPage(page)
|
cancelEdit()
|
flash(t('userdict.saved'), 'success')
|
} catch {
|
flash(t('userdict.saveFailed'), 'error')
|
}
|
}
|
|
async function toggleEntry(entry: DictEntry) {
|
try {
|
const updated: any = await Call.ByName(
|
'voicesnap/services.UserDictService.Update',
|
entry.id,
|
entry.from,
|
entry.to,
|
!entry.enabled
|
)
|
entries = entries.map(e => e.id === entry.id ? updated : e)
|
await loadPage(page)
|
} catch {}
|
}
|
|
async function deleteEntry(id: number) {
|
try {
|
await Call.ByName('voicesnap/services.UserDictService.Delete', id)
|
await loadPage(page)
|
} catch {}
|
}
|
|
async function exportDict() {
|
try {
|
const path: any = await Call.ByName('voicesnap/services.UserDictService.ExportToFile')
|
if (path) {
|
flash(t('userdict.exportedTo', { path }))
|
}
|
} catch {
|
flash(t('userdict.exportFailed'), 'error')
|
}
|
}
|
|
function chooseImportFile() {
|
jsonImportInput?.click()
|
}
|
|
async function previewCorrectionCsv() {
|
if (isPreviewingCorrectionCsv) return
|
|
isPreviewingCorrectionCsv = true
|
try {
|
const preview: any = await Call.ByName('voicesnap/services.CorrectionCSVService.PreviewCorrectionCSVFromFile')
|
if (preview?.canceled) return
|
correctionPreview = preview
|
} catch {
|
flash(t('userdict.importCorrectionCsvFailed'), 'error')
|
} finally {
|
isPreviewingCorrectionCsv = false
|
}
|
}
|
|
async function confirmCorrectionImport() {
|
if (!correctionPreview?.previewId || isConfirmingCorrectionImport) return
|
|
isConfirmingCorrectionImport = true
|
try {
|
const result: any = await Call.ByName(
|
'voicesnap/services.CorrectionCSVService.ConfirmCorrectionCSVImport',
|
correctionPreview.previewId
|
)
|
correctionPreview = null
|
await loadPage(1)
|
flash(t('userdict.importCorrectionCsvSuccess', { count: String(result?.added || 0) }), 'success')
|
} catch {
|
flash(t('userdict.importCorrectionCsvFailed'), 'error')
|
} finally {
|
isConfirmingCorrectionImport = false
|
}
|
}
|
|
async function importDict(e: Event) {
|
const input = e.target as HTMLInputElement
|
const file = input.files?.[0]
|
if (!file) return
|
|
try {
|
const content = await file.text()
|
await Call.ByName('voicesnap/services.UserDictService.ImportJSON', content)
|
await loadPage(1)
|
flash(t('userdict.importJsonSuccess'), 'success')
|
} catch {
|
flash(t('userdict.importJsonFailed'), 'error')
|
} finally {
|
input.value = ''
|
}
|
}
|
|
function goToPage(targetPage: number) {
|
if (isPageLoading) return
|
loadPage(targetPage)
|
}
|
|
function commitPageInput() {
|
const parsed = Number.parseInt(pageInput.trim(), 10)
|
if (!Number.isFinite(parsed)) {
|
pageInput = String(page)
|
return
|
}
|
const max = totalPages > 0 ? totalPages : 1
|
const target = Math.min(Math.max(parsed, 1), max)
|
pageInput = String(target)
|
if (target !== page) {
|
goToPage(target)
|
}
|
}
|
|
loadPage(1)
|
</script>
|
|
<div class="page">
|
<div class="header">
|
<h1 class="title">{t('userdict.title')}</h1>
|
<p class="subtitle">{t('userdict.subtitle')}</p>
|
</div>
|
|
<div class="toolbar">
|
<div class="toolbar-actions userdict-actions">
|
<button class="secondary-btn" onclick={chooseImportFile}>{t('userdict.importJson')}</button>
|
<button class="secondary-btn" onclick={exportDict}>{t('userdict.export')}</button>
|
<input
|
bind:this={jsonImportInput}
|
class="hidden-input"
|
type="file"
|
accept="application/json,.json"
|
onchange={importDict}
|
/>
|
</div>
|
<div class="toolbar-right">
|
{#if statusText}
|
<span class="status-text" class:error={statusTone === 'error'} title={statusText}>{statusText}</span>
|
{/if}
|
<button class="secondary-btn correction-btn" onclick={previewCorrectionCsv} disabled={isPreviewingCorrectionCsv}>
|
{isPreviewingCorrectionCsv ? t('userdict.readingCorrectionCsv') : t('userdict.importCorrectionCsv')}
|
</button>
|
</div>
|
</div>
|
|
{#if correctionPreview}
|
<CorrectionImportPreviewDialog
|
preview={correctionPreview}
|
confirming={isConfirmingCorrectionImport}
|
oncancel={() => correctionPreview = null}
|
onconfirm={confirmCorrectionImport}
|
/>
|
{/if}
|
|
<div class="section add-section">
|
<div class="field">
|
<label for="from">{t('userdict.from')}</label>
|
<input id="from" bind:value={fromText} placeholder={t('userdict.fromPlaceholder')} />
|
</div>
|
<div class="field">
|
<label for="to">{t('userdict.to')}</label>
|
<input id="to" bind:value={toText} placeholder={t('userdict.toPlaceholder')} />
|
</div>
|
<button class="primary-btn" onclick={addEntry} disabled={!fromText.trim()}>
|
{t('userdict.add')}
|
</button>
|
</div>
|
|
<div class="section list-section">
|
{#if !pageLoaded || isPageLoading}
|
<div class="empty">
|
<p class="empty-title">{t('userdict.loading')}</p>
|
</div>
|
{:else if total === 0}
|
<div class="empty">
|
<p class="empty-title">{t('userdict.empty')}</p>
|
<p class="empty-desc">{t('userdict.emptyDesc')}</p>
|
</div>
|
{:else if entries.length === 0}
|
<div class="empty">
|
<p class="empty-title">{t('userdict.pageEmpty')}</p>
|
</div>
|
{:else}
|
{#each entries as entry, i}
|
{#if i > 0}
|
<div class="divider"></div>
|
{/if}
|
<div class="dict-row" class:disabled={!entry.enabled}>
|
<button
|
class="enable-btn"
|
class:enabled={entry.enabled}
|
title={entry.enabled ? t('userdict.disable') : t('userdict.enable')}
|
onclick={() => toggleEntry(entry)}
|
>
|
{entry.enabled ? '✓' : ''}
|
</button>
|
|
{#if editingId === entry.id}
|
<div class="edit-grid">
|
<input bind:value={editFrom} aria-label={t('userdict.from')} />
|
<input bind:value={editTo} aria-label={t('userdict.to')} />
|
</div>
|
<div class="row-actions">
|
<button class="text-btn" onclick={() => saveEdit(entry)} disabled={!editFrom.trim()}>
|
{t('userdict.save')}
|
</button>
|
<button class="icon-btn" title={t('userdict.cancel')} onclick={cancelEdit}>×</button>
|
</div>
|
{:else}
|
<div class="dict-content">
|
<span class="from-text">{entry.from}</span>
|
<span class="arrow">→</span>
|
<span class="to-text">{entry.to}</span>
|
</div>
|
<div class="row-actions">
|
<button class="text-btn" onclick={() => startEdit(entry)}>{t('userdict.edit')}</button>
|
<button class="icon-btn" title={t('userdict.delete')} onclick={() => deleteEntry(entry.id)}>×</button>
|
</div>
|
{/if}
|
</div>
|
{/each}
|
{/if}
|
</div>
|
|
{#if hasPagination()}
|
<div class="pagination">
|
<button class="page-btn" onclick={() => goToPage(1)} disabled={!canGoPrev() || isPageLoading}>{t('history.firstPage')}</button>
|
<button class="page-btn" onclick={() => goToPage(page - 1)} disabled={!canGoPrev() || isPageLoading}>{t('history.prevPage')}</button>
|
<span class="page-input-wrap">
|
<input
|
class="page-input"
|
value={pageInput}
|
oninput={(e) => pageInput = (e.currentTarget as HTMLInputElement).value}
|
onkeydown={(e) => { if (e.key === 'Enter') commitPageInput() }}
|
onblur={commitPageInput}
|
aria-label={t('history.pageInput')}
|
/>
|
<span>{t('history.totalPages', { total: String(totalPages) })}</span>
|
</span>
|
<span class="page-total">{t('history.totalEntries', { total: String(total) })}</span>
|
<button class="page-btn" onclick={() => goToPage(page + 1)} disabled={!canGoNext() || isPageLoading}>{t('history.nextPage')}</button>
|
<button class="page-btn" onclick={() => goToPage(totalPages)} disabled={!canGoNext() || isPageLoading}>{t('history.lastPage')}</button>
|
</div>
|
{/if}
|
</div>
|
|
<style>
|
.page {
|
display: flex;
|
flex-direction: column;
|
min-height: calc(100vh - 40px);
|
}
|
|
.header {
|
margin-bottom: var(--spacing-md);
|
}
|
|
.title {
|
font-size: 22px;
|
font-weight: 700;
|
}
|
|
.subtitle {
|
font-size: var(--font-size-sm);
|
color: var(--color-secondary-label);
|
margin-top: 6px;
|
}
|
|
.toolbar {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
gap: var(--spacing-md);
|
min-height: 30px;
|
margin-bottom: var(--spacing-md);
|
}
|
|
.toolbar-actions,
|
.toolbar-right,
|
.row-actions {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
|
.toolbar-right {
|
margin-left: auto;
|
min-width: 0;
|
}
|
|
.userdict-actions {
|
flex-shrink: 0;
|
}
|
|
.correction-btn {
|
flex-shrink: 0;
|
}
|
|
.status-text {
|
font-size: var(--font-size-sm);
|
color: var(--color-green);
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
min-width: 0;
|
text-align: right;
|
}
|
|
.status-text.error {
|
color: var(--color-red);
|
}
|
|
.hidden-input {
|
display: none;
|
}
|
|
.section {
|
background: var(--color-bg-grouped-secondary);
|
border-radius: var(--radius-md);
|
padding: var(--spacing-lg);
|
margin-bottom: var(--spacing-md);
|
}
|
|
.add-section {
|
display: grid;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
gap: var(--spacing-md);
|
align-items: end;
|
}
|
|
.field {
|
display: flex;
|
flex-direction: column;
|
gap: 6px;
|
}
|
|
label {
|
font-size: var(--font-size-sm);
|
color: var(--color-secondary-label);
|
}
|
|
input {
|
width: 100%;
|
height: 34px;
|
border: 1px solid var(--color-separator);
|
border-radius: var(--radius-sm);
|
padding: 0 10px;
|
font: inherit;
|
color: var(--color-label);
|
background: var(--color-bg-secondary);
|
outline: none;
|
}
|
|
input:focus {
|
border-color: var(--color-blue);
|
background: var(--color-bg-primary);
|
}
|
|
button {
|
font: inherit;
|
cursor: pointer;
|
transition: opacity var(--transition-fast), background var(--transition-fast);
|
}
|
|
button:disabled {
|
cursor: default;
|
opacity: 0.45;
|
}
|
|
.primary-btn,
|
.secondary-btn,
|
.text-btn {
|
border: none;
|
border-radius: var(--radius-sm);
|
font-size: var(--font-size-sm);
|
font-weight: 600;
|
}
|
|
.primary-btn {
|
height: 34px;
|
padding: 0 16px;
|
color: white;
|
background: var(--color-blue);
|
}
|
|
.secondary-btn {
|
height: 28px;
|
padding: 0 12px;
|
color: var(--color-blue);
|
background: rgba(0, 122, 255, 0.08);
|
}
|
|
.text-btn {
|
color: var(--color-blue);
|
background: transparent;
|
}
|
|
.icon-btn,
|
.enable-btn {
|
border: none;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
flex-shrink: 0;
|
}
|
|
.icon-btn {
|
width: 26px;
|
height: 26px;
|
border-radius: 50%;
|
color: var(--color-secondary-label);
|
background: transparent;
|
font-size: 20px;
|
line-height: 1;
|
}
|
|
.icon-btn:hover {
|
background: rgba(0, 0, 0, 0.05);
|
color: var(--color-red);
|
}
|
|
.enable-btn {
|
width: 20px;
|
height: 20px;
|
border-radius: 50%;
|
border: 1px solid var(--color-separator);
|
color: white;
|
background: var(--color-bg-secondary);
|
font-size: 13px;
|
font-weight: 700;
|
}
|
|
.enable-btn.enabled {
|
border-color: var(--color-green);
|
background: var(--color-green);
|
}
|
|
.list-section {
|
flex: 1;
|
overflow: auto;
|
}
|
|
.dict-row {
|
display: grid;
|
grid-template-columns: 20px minmax(0, 1fr) auto;
|
gap: var(--spacing-md);
|
align-items: center;
|
min-height: 48px;
|
}
|
|
.dict-row.disabled .dict-content {
|
opacity: 0.45;
|
}
|
|
.dict-content {
|
display: grid;
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
gap: 10px;
|
align-items: center;
|
min-width: 0;
|
}
|
|
.from-text,
|
.to-text {
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
font-size: var(--font-size-base);
|
}
|
|
.from-text {
|
color: var(--color-label);
|
}
|
|
.to-text {
|
color: var(--color-blue);
|
font-weight: 500;
|
}
|
|
.arrow {
|
color: var(--color-tertiary-label);
|
}
|
|
.edit-grid {
|
display: grid;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
gap: var(--spacing-md);
|
}
|
|
.divider {
|
height: 1px;
|
background: var(--color-separator);
|
margin: 6px 0 6px 32px;
|
}
|
|
.empty {
|
text-align: center;
|
padding: var(--spacing-xxl) 0;
|
}
|
|
.empty-title {
|
font-weight: 600;
|
color: var(--color-secondary-label);
|
}
|
|
.empty-desc {
|
font-size: var(--font-size-sm);
|
color: var(--color-tertiary-label);
|
margin-top: 6px;
|
}
|
|
.pagination {
|
display: flex;
|
align-items: center;
|
justify-content: flex-end;
|
gap: 8px;
|
padding: 0 4px;
|
color: var(--color-secondary-label);
|
font-size: var(--font-size-xs);
|
flex-shrink: 0;
|
}
|
|
.page-btn {
|
height: 26px;
|
padding: 0 8px;
|
border: none;
|
border-radius: var(--radius-sm);
|
background: transparent;
|
color: var(--color-blue);
|
cursor: pointer;
|
font-size: var(--font-size-xs);
|
}
|
|
.page-btn:hover:not(:disabled) {
|
background: var(--color-bg-secondary);
|
}
|
|
.page-btn:disabled {
|
color: var(--color-tertiary-label);
|
cursor: default;
|
opacity: 0.55;
|
}
|
|
.page-input-wrap {
|
display: flex;
|
align-items: center;
|
gap: 5px;
|
}
|
|
.page-input {
|
width: 42px;
|
height: 24px;
|
padding: 0 6px;
|
border: 1px solid var(--color-separator);
|
border-radius: var(--radius-sm);
|
background: var(--color-bg-grouped-secondary);
|
color: var(--color-label);
|
font-size: var(--font-size-xs);
|
text-align: center;
|
}
|
|
.page-input:focus {
|
outline: none;
|
border-color: var(--color-blue);
|
}
|
|
.page-total {
|
color: var(--color-tertiary-label);
|
}
|
</style>
|