feat: PDF 转换前端(epub 上传/进度轮询/下载/用户软删,复用 zTools2 API)

便携式 Vue3 前端:上传 epub 转 PDF,显示上传与转换进度,完成后下载;
用户可软删任务(不再对自己展示,管理员仍可见)。凭 cookie 标识用户。
复用 zTools2 后端 /api/pdf/*(同源),独立 i18n(中英懒加载),
可独立运行或经 mainPage iframe 同源集成。含 vitest 单测 20 项。
This commit is contained in:
2026-07-27 11:33:26 +08:00
commit 30f5cee4a1
19 changed files with 3918 additions and 0 deletions

131
src/App.vue Normal file
View File

@@ -0,0 +1,131 @@
<script setup>
import { ref, onUnmounted } from 'vue'
import { usePdfApi, validateFile, uploadFile } from './composables/usePdfApi.js'
import { useLocale } from './composables/useLocale.js'
import { loadLocaleAsync } from './i18n/index.js'
import Uploader from './components/Uploader.vue'
import JobList from './components/JobList.vue'
const props = defineProps({
locale: { type: String, default: 'zh-CN' },
})
const { t } = useLocale()
const api = usePdfApi()
// 跟随父项目语言开关切换本子项目 i18n 实例的 locale
import { watch } from 'vue'
watch(
() => props.locale,
(val) => {
if (val) loadLocaleAsync(val)
},
{ immediate: true }
)
const items = ref([])
const total = ref(0)
const loading = ref(false)
const loadError = ref('')
let stopPoll = null
async function refresh() {
loading.value = true
loadError.value = ''
try {
const r = await api.load()
items.value = r.items
total.value = r.total
startPoll()
} catch (e) {
loadError.value = String(e.message || e)
} finally {
loading.value = false
}
}
function startPoll() {
if (stopPoll) stopPoll()
stopPoll = api.pollPending(
items.value,
(fresh) => {
items.value = fresh
},
null
)
}
async function onUpload(file, onProgress) {
const err = validateFile(file)
if (err) {
throw new Error(t(err))
}
const res = await uploadFile(file, onProgress)
// 上传成功后刷新列表以纳入新任务并开始轮询
await refresh()
return res
}
async function onDelete(job) {
await api.remove(job.id)
await refresh()
}
onUnmounted(() => {
if (stopPoll) stopPoll()
})
refresh()
</script>
<template>
<div class="zpdf-app">
<header class="zpdf-header">
<h1>{{ t('app.title') }}</h1>
</header>
<main class="zpdf-main">
<Uploader @upload="onUpload" />
<JobList
:items="items"
:total="total"
:loading="loading"
:load-error="loadError"
@refresh="refresh"
@delete="onDelete"
/>
</main>
</div>
</template>
<style scoped>
/* scoped样式仅作用于本组件作为组件被嵌入时不污染宿主。
全局重置与设计令牌在 src/styles/global.css独立 app/ 宿主 :root嵌入时。 */
.zpdf-app {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
background: var(--bg);
color: var(--text-primary);
}
.zpdf-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
flex-shrink: 0;
}
.zpdf-header h1 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.zpdf-main {
flex: 1;
overflow-y: auto;
padding: 16px 20px 32px;
}
@media (max-width: 768px) {
.zpdf-header { padding: 10px 12px; }
.zpdf-main { padding: 12px 12px 24px; }
}
</style>

165
src/components/JobList.vue Normal file
View File

@@ -0,0 +1,165 @@
<script setup>
import { useLocale } from '../composables/useLocale.js'
import { usePdfApi, fmtBytes } from '../composables/usePdfApi.js'
const props = defineProps({
items: { type: Array, default: () => [] },
total: { type: Number, default: 0 },
loading: { type: Boolean, default: false },
loadError: { type: String, default: '' },
})
const emit = defineEmits(['refresh', 'delete'])
const { t } = useLocale()
const api = usePdfApi()
function statusText(job) {
if (job.status === 'done') return t('job.statusDone')
if (job.status === 'failed') return t('job.statusFailed')
if (job.status === 'converting') return t('job.statusConverting')
return t('job.statusPending')
}
function statusClass(job) {
if (job.status === 'done') return 'zpdf-tag zpdf-tag--ok'
if (job.status === 'failed') return 'zpdf-tag zpdf-tag--err'
return 'zpdf-tag zpdf-tag--warn'
}
function fmtTime(s) {
if (!s) return '-'
const d = new Date(s)
if (isNaN(d.getTime())) return s
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
async function onDelete(job) {
if (!confirm(t('job.deleteConfirm', { name: job.source_filename }))) return
emit('delete', job)
}
</script>
<template>
<div class="zpdf-list">
<div class="zpdf-toolbar">
<button class="zpdf-btn zpdf-btn--primary zpdf-btn--sm" @click="emit('refresh')">
{{ t('refresh') }}
</button>
<span class="zpdf-muted">{{ t('list.count', { total }) }}</span>
</div>
<div v-if="loading" class="zpdf-skel">{{ t('list.loading') }}</div>
<div v-else-if="loadError" class="zpdf-empty">
{{ t('list.loadFailed', { msg: loadError }) }}
</div>
<div v-else-if="!items.length" class="zpdf-empty">{{ t('list.empty') }}</div>
<table v-else class="zpdf-table">
<thead>
<tr>
<th class="col-name">{{ t('job.name') }}</th>
<th class="col-size">{{ t('job.size') }}</th>
<th class="col-status">{{ t('job.status') }}</th>
<th class="col-time">{{ t('job.created') }}</th>
<th class="col-act">{{ t('job.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="j in items" :key="j.id">
<td class="col-name">{{ j.source_filename }}</td>
<td class="col-size zpdf-mono">{{ fmtBytes(j.source_size) }}</td>
<td class="col-status">
<span v-if="j.status === 'done' || j.status === 'failed'" :class="statusClass(j)">
{{ statusText(j) }}
</span>
<span v-else class="zpdf-prog">
<span class="zpdf-prog__label">{{ statusText(j) }}</span>
<span class="zpdf-bar zpdf-bar--sm">
<span class="zpdf-bar__fill" :style="{ width: (j.progress || 0) + '%' }"></span>
</span>
</span>
</td>
<td class="col-time zpdf-muted">{{ fmtTime(j.created_at) }}</td>
<td class="col-act">
<a
v-if="j.status === 'done'"
class="zpdf-btn zpdf-btn--primary zpdf-btn--sm"
:href="api.downloadUrl(j.id)"
download
>{{ t('job.download') }}</a>
<button class="zpdf-btn zpdf-btn--danger zpdf-btn--sm" @click="onDelete(j)">
{{ t('job.delete') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.zpdf-toolbar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 16px;
}
.zpdf-muted { color: var(--text-secondary); font-size: 0.82em; }
.zpdf-mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.85em; }
.zpdf-skel, .zpdf-empty {
padding: 28px; text-align: center; color: var(--text-secondary); font-size: 0.9em;
}
.zpdf-table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
.zpdf-table th, .zpdf-table td {
text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border);
}
.zpdf-table th {
color: var(--text-secondary); font-weight: 600; font-size: 0.82em;
text-transform: uppercase; letter-spacing: 0.04em;
}
.zpdf-table tbody tr:hover td { background: var(--surface); }
.zpdf-table .col-size { width: 8em; }
.zpdf-table .col-status { width: 12em; }
.zpdf-table .col-time { width: 11em; }
.zpdf-table .col-act { width: 10em; text-align: right; white-space: nowrap; }
.zpdf-table .col-name { font-weight: 600; word-break: break-all; }
.zpdf-table .col-act .zpdf-btn { margin-left: 6px; }
.zpdf-tag {
display: inline-block; padding: 2px 10px; border-radius: 10px;
font-size: 0.78em; background: var(--surface-2); color: var(--text-secondary);
}
.zpdf-tag--ok { background: var(--success-weak); color: var(--success); }
.zpdf-tag--err { background: var(--danger-weak); color: var(--danger); }
.zpdf-tag--warn { background: var(--warn-weak); color: var(--warn); }
.zpdf-prog { display: inline-flex; align-items: center; gap: 8px; font-size: 0.82em; }
.zpdf-prog__label { color: var(--text-secondary); white-space: nowrap; }
.zpdf-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 18px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--surface);
color: var(--text-primary); cursor: pointer; font-size: 0.92em;
text-decoration: none; transition: background var(--transition), border-color var(--transition);
}
.zpdf-btn:hover { background: var(--surface-2); }
.zpdf-btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.zpdf-btn--primary:hover { background: var(--accent-hover); }
.zpdf-btn--danger { color: var(--danger); border-color: var(--danger-weak); }
.zpdf-btn--danger:hover { background: var(--danger-weak); }
.zpdf-btn--sm { padding: 5px 12px; font-size: 0.85em; }
.zpdf-bar {
height: 8px; background: var(--surface-2); border-radius: 6px; overflow: hidden;
border: 1px solid var(--border);
}
.zpdf-bar__fill {
height: 100%; width: 0; background: var(--accent); border-radius: 6px;
transition: width 0.2s;
}
.zpdf-bar--sm { display: inline-block; width: 90px; vertical-align: middle; height: 6px; }
@media (max-width: 640px) {
.zpdf-table th, .zpdf-table td { padding: 8px 6px; }
.zpdf-table .col-time { font-size: 0.78em; }
}
</style>

131
src/components/Uploader.vue Normal file
View File

@@ -0,0 +1,131 @@
<script setup>
import { ref } from 'vue'
import { useLocale } from '../composables/useLocale.js'
const emit = defineEmits(['upload'])
const { t } = useLocale()
const fileInput = ref(null)
const isDrag = ref(false)
const uploading = ref(false)
const uploadName = ref('')
const uploadPercent = ref(0)
const errMsg = ref('')
function pick() {
fileInput.value && fileInput.value.click()
}
function onDragOver(e) {
e.preventDefault()
isDrag.value = true
}
function onDragLeave() {
isDrag.value = false
}
function onDrop(e) {
e.preventDefault()
isDrag.value = false
if (e.dataTransfer.files.length) handle(e.dataTransfer.files[0])
}
function onChange(e) {
if (e.target.files.length) handle(e.target.files[0])
e.target.value = ''
}
async function handle(file) {
errMsg.value = ''
uploading.value = true
uploadName.value = file.name
uploadPercent.value = 0
try {
await emit('upload', file, (p) => {
uploadPercent.value = p
})
} catch (e) {
errMsg.value = e.message || String(e)
} finally {
uploading.value = false
uploadPercent.value = 0
uploadName.value = ''
}
}
</script>
<template>
<div class="zpdf-uploader">
<div
class="zpdf-dropzone"
:class="{ 'is-drag': isDrag }"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<div class="zpdf-dropzone__hint">{{ t('upload.hint') }}</div>
<button type="button" class="zpdf-btn zpdf-btn--primary" @click="pick">
{{ t('upload.pick') }}
</button>
<input
ref="fileInput"
type="file"
accept=".epub,application/epub+zip"
hidden
@change="onChange"
/>
<div class="zpdf-dropzone__limit">{{ t('upload.limit') }}</div>
</div>
<div v-if="uploading" class="zpdf-upload-progress">
<div class="zpdf-upload-progress__name">{{ uploadName }}</div>
<div class="zpdf-bar"><div class="zpdf-bar__fill" :style="{ width: uploadPercent + '%' }"></div></div>
</div>
<div v-if="errMsg" class="zpdf-err">{{ errMsg }}</div>
</div>
</template>
<style scoped>
.zpdf-uploader { margin-bottom: 24px; }
.zpdf-dropzone {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; padding: 40px 20px; min-height: 160px;
border: 2px dashed var(--border-strong); border-radius: var(--radius);
background: var(--surface); text-align: center;
transition: border-color var(--transition), background var(--transition);
}
.zpdf-dropzone.is-drag { border-color: var(--accent); background: var(--accent-weak); }
.zpdf-dropzone__hint { color: var(--text-secondary); font-size: 0.95em; }
.zpdf-dropzone__limit { color: var(--text-tertiary); font-size: 0.8em; }
.zpdf-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 18px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--surface);
color: var(--text-primary); cursor: pointer; font-size: 0.92em;
transition: background var(--transition), border-color var(--transition);
}
.zpdf-btn:hover { background: var(--surface-2); }
.zpdf-btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.zpdf-btn--primary:hover { background: var(--accent-hover); }
.zpdf-btn--danger { color: var(--danger); border-color: var(--danger-weak); }
.zpdf-btn--danger:hover { background: var(--danger-weak); }
.zpdf-btn--sm { padding: 5px 12px; font-size: 0.85em; }
.zpdf-upload-progress { margin-top: 12px; }
.zpdf-upload-progress__name { font-size: 0.88em; margin-bottom: 6px; word-break: break-all; }
.zpdf-bar {
height: 8px; background: var(--surface-2); border-radius: 6px; overflow: hidden;
border: 1px solid var(--border);
}
.zpdf-bar__fill {
height: 100%; width: 0; background: var(--accent); border-radius: 6px;
transition: width 0.2s;
}
.zpdf-bar--sm { display: inline-block; width: 90px; vertical-align: middle; height: 6px; }
.zpdf-err {
margin-top: 10px; padding: 8px 12px; border-radius: var(--radius-sm);
background: var(--danger-weak); color: var(--danger); font-size: 0.88em;
}
</style>

View File

@@ -0,0 +1,14 @@
import { computed } from 'vue'
import i18n from '../i18n/index.js'
// ★ 子项目自有的语言辅助 composable。
// 关键:直接读模块级 i18n 实例的 global composer而非 useI18n() 注入。
// 原因useI18n({ useScope: 'global' }) 解析的是「当前运行 app 已安装的全局 i18n」
// 嵌入 mainPage 时那是父项目的实例(不含本子项目文案)-> 文案回退成 key。
// 改为直接引用本子项目实例的 .global无论是否 app.use() 都拿到本子项目的 t。
export function useLocale() {
const { t, locale } = i18n.global
const isZh = computed(() => locale.value === 'zh-CN')
const pick = (obj) => (isZh.value ? obj.zh : obj.en)
return { t, locale, isZh, pick }
}

View File

@@ -0,0 +1,174 @@
// ★ PDF 转换 API 客户端 + 进度轮询状态机(与 DOM 解耦,便于单元测试)。
//
// 职责:
// - upload(file): XMLHttpRequest 上传,报告上传进度
// - load(): 拉取当前用户任务列表
// - pollPending(items, onUpdate, onDone): 轮询未完成任务直至终态
// - remove(id): 用户软删
// - downloadUrl(id): 产物下载 URL
//
// 同源调用 zTools2 的 /api/pdf/*cookiezk_pdf由浏览器自动管理无需手动处理。
// fetch 默认 same-origin带上同源 cookie。
const POLL_INTERVAL_MS = 1500
/** 终态判定done/failed 不再轮询。 */
export function isTerminal(job) {
return !!job && (job.status === 'done' || job.status === 'failed')
}
/** 格式化字节为人类可读(供组件与测试复用)。 */
export function fmtBytes(n) {
const x = Number(n)
if (n == null || !isFinite(x)) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let v = x
while (Math.abs(v) >= 1000 && i < units.length - 1) {
v /= 1000
i++
}
return i === 0 ? `${Math.round(v)} ${units[i]}` : `${v.toFixed(1)} ${units[i]}`
}
/** 上传前客户端校验:扩展名 + 大小。返回 null 表示通过,否则返回错误 i18n key。 */
export function validateFile(file, maxBytes = 250 * 1024 * 1024) {
const name = (file && file.name) || ''
if (!name.toLowerCase().endsWith('.epub')) return 'upload.requireEpub'
if (file.size > maxBytes) return 'upload.oversize'
return null
}
/**
* 创建 PdfApi 实例。fetcher 注入便于测试(默认用全局 fetch
* @param {{ fetch?: typeof fetch }} [opts]
*/
export function createPdfApi(opts = {}) {
const fetcher = opts.fetch || (typeof fetch !== 'undefined' ? fetch : null)
async function load() {
if (!fetcher) throw new Error('fetch unavailable')
const res = await fetcher('/api/pdf/jobs', { credentials: 'same-origin' })
if (!res.ok) throw new Error('HTTP ' + res.status)
const body = await res.json()
return { total: body.total || 0, items: body.items || [] }
}
async function getJob(id) {
if (!fetcher) throw new Error('fetch unavailable')
const res = await fetcher(`/api/pdf/jobs/${id}`, { credentials: 'same-origin' })
if (!res.ok) throw new Error('HTTP ' + res.status)
return res.json()
}
async function remove(id) {
if (!fetcher) throw new Error('fetch unavailable')
const res = await fetcher(`/api/pdf/jobs/${id}`, {
method: 'DELETE',
credentials: 'same-origin',
})
if (!res.ok) throw new Error('HTTP ' + res.status)
return res.json()
}
function downloadUrl(id) {
return `/api/pdf/jobs/${id}/download`
}
/**
* 轮询未完成任务直至全部终态。每次拉取后调 onUpdate(freshItems),全部终态后调 onDone()。
* 返回 stop 函数,可提前停止(如组件卸载)。
* @param {Array} items 任务列表快照
* @param {(freshItems: Array) => void} onUpdate
* @param {() => void} [onDone]
*/
function pollPending(items, onUpdate, onDone) {
const pendingIds = items.filter((j) => !isTerminal(j)).map((j) => j.id)
if (pendingIds.length === 0) {
if (onDone) onDone()
return () => {}
}
let stopped = false
const timer = setInterval(async () => {
if (stopped) return
let stillPending = false
const fresh = []
for (const id of pendingIds) {
try {
fresh.push(await getJob(id))
} catch {
// 单个查询失败不中断整体轮询
fresh.push(items.find((j) => j.id === id))
}
}
// 合并:用 fresh 更新对应项,保留已完成项原值
const byId = new Map(fresh.map((j) => [j.id, j]))
const merged = items.map((j) => byId.get(j.id) || j)
for (const j of merged) {
if (!isTerminal(j)) stillPending = true
}
onUpdate(merged)
if (!stillPending) {
stop()
if (onDone) onDone()
}
}, POLL_INTERVAL_MS)
function stop() {
stopped = true
clearInterval(timer)
}
return stop
}
return { load, getJob, remove, downloadUrl, pollPending }
}
/**
* 组件用便捷工厂:返回带默认 fetch 的 PdfApi 实例composable 风格命名)。
* 测试用 createPdfApi({ fetch }) 注入 mock。
*/
export function usePdfApi() {
return createPdfApi()
}
/**
* 上传文件XMLHttpRequest 以支持上传进度回调)。返回 Promise<{job}>。
* @param {File} file
* @param {(percent: number) => void} [onProgress] 上传百分比 0-100
* @param {{ xhr?: typeof XMLHttpRequest }} [opts] 注入便于测试
*/
export function uploadFile(file, onProgress, opts = {}) {
const Xhr = opts.xhr || (typeof XMLHttpRequest !== 'undefined' ? XMLHttpRequest : null)
return new Promise((resolve, reject) => {
if (!Xhr) {
reject(new Error('XMLHttpRequest unavailable'))
return
}
const xhr = new Xhr()
xhr.open('POST', '/api/pdf/jobs')
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
}
xhr.onload = () => {
if (xhr.status === 200) {
try {
resolve(JSON.parse(xhr.responseText))
} catch (e) {
reject(new Error('bad response'))
}
} else {
let detail = ''
try {
detail = JSON.parse(xhr.responseText).detail || ''
} catch {}
reject(new Error(detail || 'HTTP ' + xhr.status))
}
}
xhr.onerror = () => reject(new Error('network error'))
const fd = new FormData()
fd.append('file', file)
xhr.send(fd)
})
}

46
src/i18n/index.js Normal file
View File

@@ -0,0 +1,46 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.js'
// ★ zPDF_package 独立 i18n 实例。中文同步注入(默认语言);英文动态 import() 懒加载。
// 嵌入 mainPage 时,父项目把当前语言经 :locale prop 传入 App.vuewatch 同步到本实例,
// 从而跟随父项目语言开关切换(不共享父的 messages仅同步 locale 值,文案来自本子项目自有 locale
// 注:组件内不用 useI18n() 注入(会解析到宿主实例),改用 ./composables/useLocale.js 直接读本实例。
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN
}
})
const loadedLocales = new Set(['zh-CN'])
/**
* 懒加载并切换到目标语言。首次切换某语言时动态 import 其语言包并 mergeLocaleMessage。
* @param {string} locale
*/
export async function loadLocaleAsync(locale) {
if (i18n.global.locale.value === locale) return
if (loadedLocales.has(locale)) {
i18n.global.locale.value = locale
document.documentElement.setAttribute('lang', locale)
return
}
let messages
if (locale === 'en') {
messages = (await import('./locales/en.js')).default
} else {
i18n.global.locale.value = 'zh-CN'
document.documentElement.setAttribute('lang', 'zh-CN')
return
}
i18n.global.mergeLocaleMessage(locale, messages)
loadedLocales.add(locale)
i18n.global.locale.value = locale
document.documentElement.setAttribute('lang', locale)
}
export default i18n

41
src/i18n/locales/en.js Normal file
View File

@@ -0,0 +1,41 @@
// 英文语言包 -- 通过动态 import() 加载,被打成独立 chunk
// 只看中文的访客不会下载此文件。
export default {
app: {
title: 'PDF Converter'
},
upload: {
hint: 'Drop an .epub file, or',
pick: 'click to choose',
limit: 'Only epub, up to 250MB',
requireEpub: 'Only epub files are supported',
oversize: 'File exceeds the 250MB limit',
uploading: 'Uploading…',
submitted: 'Submitted, converting…',
failed: 'Upload failed',
networkError: 'Network error'
},
list: {
count: '{total} task(s)',
empty: 'No tasks yet. Upload an epub to start.',
loading: 'Loading…',
loadFailed: 'Load failed: {msg}'
},
job: {
name: 'Filename',
size: 'Size',
status: 'Status',
created: 'Created',
actions: 'Actions',
statusDone: 'Done',
statusFailed: 'Failed',
statusConverting: 'Converting',
statusPending: 'Queued',
download: 'Download',
delete: 'Delete',
deleteConfirm: 'Delete "{name}"?\nIt will no longer be shown to you (admin can still see it; only admin hard-delete truly removes it).',
deleted: 'Deleted',
deleteFailed: 'Delete failed: {msg}'
},
refresh: 'Refresh'
}

40
src/i18n/locales/zh-CN.js Normal file
View File

@@ -0,0 +1,40 @@
// 中文语言包(默认语言,同步注入主 chunk
export default {
app: {
title: 'PDF 转换'
},
upload: {
hint: '拖入 .epub 文件,或',
pick: '点击选择文件',
limit: '仅支持 epub最大 250MB',
requireEpub: '仅支持 epub 文件',
oversize: '文件超过 250MB 上限',
uploading: '上传中…',
submitted: '已提交,转换中…',
failed: '上传失败',
networkError: '网络错误'
},
list: {
count: '共 {total} 个任务',
empty: '还没有任务。上传一个 epub 试试吧。',
loading: '加载中…',
loadFailed: '加载失败:{msg}'
},
job: {
name: '文件名',
size: '大小',
status: '状态',
created: '创建时间',
actions: '操作',
statusDone: '完成',
statusFailed: '失败',
statusConverting: '转换中',
statusPending: '排队中',
download: '下载',
delete: '删除',
deleteConfirm: '确定删除「{name}」?\n删除后将不再显示管理员仍可见需管理员彻底删除才会清除。',
deleted: '已删除',
deleteFailed: '删除失败:{msg}'
},
refresh: '刷新'
}

6
src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import i18n from './i18n/index.js'
import './styles/global.css'
createApp(App).use(i18n).mount('#app')

36
src/styles/global.css Normal file
View File

@@ -0,0 +1,36 @@
/* 全局重置 + 设计令牌 -- 仅独立 appmain.js加载。
作为组件被嵌入时由宿主(mainPage)提供 :root 令牌,本文件的重置仍安全(与宿主一致)。
令牌值与 mainPage src/styles/variables.css 对齐,保证独立与嵌入视觉一致。 */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", sans-serif;
background: var(--bg, #ffffff);
color: var(--text-primary, #1f2329);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
:root {
--bg: #ffffff;
--surface: #f7f8fa;
--surface-2: #f0f2f5;
--text-primary: #1f2329;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--accent: #4f46e5;
--accent-weak: #eef2ff;
--accent-hover: #4338ca;
--border: #eceef1;
--border-strong: #e0e2e6;
--success: #2e7d32;
--success-weak: #e8f5e9;
--danger: #c62828;
--danger-weak: #fbe9e7;
--warn: #e65100;
--warn-weak: #fff3e0;
--radius-sm: 8px;
--radius: 12px;
--shadow: 0 1px 3px rgba(17, 24, 39, 0.06), 0 1px 2px rgba(17, 24, 39, 0.04);
--transition: 0.18s ease;
}