Import project files
This commit is contained in:
448
frontend/src/components/BatchProcess.vue
Normal file
448
frontend/src/components/BatchProcess.vue
Normal file
@@ -0,0 +1,448 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { convertFolder, stageArchive, processArchive, uploadList, sendImportToCms, setCmsConfig } from '../services/api'
|
||||
|
||||
const mode = ref<'folder' | 'archive' | 'list'>('folder')
|
||||
const folderPath = ref('')
|
||||
const prefix = ref('')
|
||||
const file = ref<File | null>(null)
|
||||
const stagedId = ref('')
|
||||
const versionId = ref<number>(1001)
|
||||
const listFile = ref<File | null>(null)
|
||||
const loading = ref(false)
|
||||
const result = ref<any>(null)
|
||||
const error = ref('')
|
||||
const showRaw = ref(false)
|
||||
const cmsConfigured = ref(false)
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const selectedFile = target.files?.[0]
|
||||
if (selectedFile) {
|
||||
file.value = selectedFile
|
||||
}
|
||||
}
|
||||
|
||||
function onListFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const selectedFile = target.files?.[0]
|
||||
if (selectedFile) {
|
||||
listFile.value = selectedFile
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCmsConfig(): Promise<boolean> {
|
||||
try {
|
||||
const base = (localStorage.getItem('cms.api.base') || '').trim()
|
||||
if (base) { cmsConfigured.value = true; return true }
|
||||
const b = window.prompt('请输入 CMS 接口地址(如 http://127.0.0.1:8080 )')
|
||||
if (!b) return false
|
||||
const t = window.prompt('可选:请输入 Bearer Token(若接口需要认证)') || ''
|
||||
setCmsConfig(b, t)
|
||||
cmsConfigured.value = true
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
async function sendToCms() {
|
||||
if (!result.value || !result.value.import) {
|
||||
alert('没有导入JSON可发送')
|
||||
return
|
||||
}
|
||||
const ok = await ensureCmsConfig()
|
||||
if (!ok) return
|
||||
const r = await sendImportToCms(result.value.import)
|
||||
if (r.ok) {
|
||||
alert('已发送到 CMS 导入接口')
|
||||
} else {
|
||||
alert(`发送失败:${r.error || '未知错误'}${r.status ? ' (HTTP '+r.status+')' : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStageArchive() {
|
||||
if (!file.value) {
|
||||
error.value = '请选择一个压缩包(zip、tar、tgz)'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
try {
|
||||
const res = await stageArchive(file.value, prefix.value || undefined)
|
||||
if (res.code === 0) {
|
||||
stagedId.value = res.data.id
|
||||
} else {
|
||||
error.value = res.msg || '上传失败'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = '网络错误或上传失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProcessArchive() {
|
||||
if (!stagedId.value) {
|
||||
error.value = '请先上传压缩包'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
try {
|
||||
const res = await processArchive(stagedId.value, prefix.value || undefined, versionId.value)
|
||||
if (res.code === 0) {
|
||||
result.value = res.data
|
||||
} else {
|
||||
error.value = res.msg || '处理失败'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = '网络错误或处理失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConvertFolder() {
|
||||
if (!folderPath.value.trim()) {
|
||||
error.value = '请输入本地文件夹路径'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
try {
|
||||
const res = await convertFolder(folderPath.value, prefix.value || undefined)
|
||||
if (res.ok) {
|
||||
result.value = res
|
||||
} else {
|
||||
error.value = '处理失败'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = '网络错误或处理失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUploadList() {
|
||||
if (!listFile.value) {
|
||||
error.value = '请选择一个包含路径或URL的文本文件'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
try {
|
||||
const res = await uploadList(listFile.value, prefix.value || undefined, versionId.value)
|
||||
if (res.code === 0) {
|
||||
result.value = res.data
|
||||
} else {
|
||||
error.value = res.msg || '处理失败'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = '网络错误或处理失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h1>批量处理</h1>
|
||||
<div class="row">
|
||||
<label>模式</label>
|
||||
<div class="radio-group">
|
||||
<label><input type="radio" v-model="mode" value="folder"> 本地文件夹路径</label>
|
||||
<label><input type="radio" v-model="mode" value="archive"> 上传压缩包</label>
|
||||
<label><input type="radio" v-model="mode" value="list"> 路径/URL 列表</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="mode === 'folder'">
|
||||
<p class="description">输入本地文件夹路径,服务端将批量重写 Markdown 引用并上传到 MinIO。</p>
|
||||
<div class="row">
|
||||
<label>文件夹路径</label>
|
||||
<input type="text" v-model="folderPath" placeholder="/Users/xxx/Docs/Manuals" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>MinIO 前缀(可选)</label>
|
||||
<input type="text" v-model="prefix" placeholder="assets" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="handleConvertFolder" :disabled="loading">
|
||||
{{ loading ? '正在处理...' : '开始处理' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||
<div v-if="result" class="result-area">
|
||||
<h4>处理完成</h4>
|
||||
<div class="actions" style="margin-top:8px;">
|
||||
<button class="btn-secondary" @click="showRaw = !showRaw">{{ showRaw ? '隐藏返回JSON' : '显示返回JSON' }}</button>
|
||||
</div>
|
||||
<div class="actions" style="margin-top:8px;">
|
||||
<button class="btn-secondary" @click="showRaw = !showRaw">{{ showRaw ? '隐藏返回JSON' : '显示返回JSON' }}</button>
|
||||
<button class="btn-secondary" v-if="result.import" @click="sendToCms">发送到 CMS</button>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">文件数:</span>
|
||||
<span>{{ result.count }}</span>
|
||||
</div>
|
||||
<div class="files">
|
||||
<div class="file" v-for="(f, i) in result.files" :key="i">
|
||||
<div class="file-row">
|
||||
<span class="label">源文件:</span>
|
||||
<span>{{ f.source }}</span>
|
||||
</div>
|
||||
<div class="file-row" v-if="(f.minio_presigned_url || f.minio_url)">
|
||||
<span class="label">打开:</span>
|
||||
<a :href="f.minio_presigned_url || f.minio_url" target="_blank">查看</a>
|
||||
</div>
|
||||
<div class="file-row" v-if="f.minio_url">
|
||||
<span class="label">MinIO:</span>
|
||||
<a :href="f.minio_url" target="_blank">{{ f.minio_url }}</a>
|
||||
</div>
|
||||
<div class="file-row" v-if="f.minio_presigned_url">
|
||||
<span class="label">临时下载:</span>
|
||||
<a :href="f.minio_presigned_url" target="_blank">下载链接</a>
|
||||
</div>
|
||||
<div class="file-row">
|
||||
<span class="label">资源重写:</span>
|
||||
<span>成功 {{ f.asset_ok }},失败 {{ f.asset_fail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<pre v-if="showRaw" class="json-view">{{ JSON.stringify(result, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="mode === 'archive'">
|
||||
<p class="description">分两步:先上传压缩包,再点击开始转换。</p>
|
||||
<div class="row">
|
||||
<label>压缩包文件</label>
|
||||
<input type="file" accept=".zip,.tar,.gz,.tgz" @change="onFileChange" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="handleStageArchive" :disabled="loading">上传压缩包</button>
|
||||
<button @click="handleProcessArchive" :disabled="loading || !stagedId">开始转换</button>
|
||||
</div>
|
||||
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||
<div v-if="result" class="result-area">
|
||||
<h4>处理完成</h4>
|
||||
<div class="actions" style="margin-top:8px;">
|
||||
<button class="btn-secondary" @click="showRaw = !showRaw">{{ showRaw ? '隐藏返回JSON' : '显示返回JSON' }}</button>
|
||||
<button class="btn-secondary" v-if="result.import" @click="sendToCms">发送到 CMS</button>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">文件数:</span>
|
||||
<span>{{ result.count }}</span>
|
||||
</div>
|
||||
<div class="files">
|
||||
<div class="file" v-for="(f, i) in result.files" :key="i">
|
||||
<div class="file-row">
|
||||
<span class="label">源文件:</span>
|
||||
<span>{{ f.source }}</span>
|
||||
</div>
|
||||
<div class="file-row" v-if="(f.minio_presigned_url || f.minio_url)">
|
||||
<span class="label">打开:</span>
|
||||
<a :href="f.minio_presigned_url || f.minio_url" target="_blank">查看</a>
|
||||
</div>
|
||||
<div class="file-row" v-if="f.minio_url">
|
||||
<span class="label">MinIO:</span>
|
||||
<a :href="f.minio_url" target="_blank">{{ f.minio_url }}</a>
|
||||
</div>
|
||||
<div class="file-row" v-if="f.minio_presigned_url">
|
||||
<span class="label">临时下载:</span>
|
||||
<a :href="f.minio_presigned_url" target="_blank">下载链接</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item" v-if="result.import">
|
||||
<span class="label">导入JSON:</span>
|
||||
<span>{{ JSON.stringify(result.import) }}</span>
|
||||
</div>
|
||||
<pre v-if="showRaw" class="json-view">{{ JSON.stringify(result, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="description">上传一个包含本地路径或 URL 的文本文件,每行一个。</p>
|
||||
<div class="row">
|
||||
<label>列表文件</label>
|
||||
<input type="file" accept="text/plain" @change="onListFileChange" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>MinIO 前缀(可选)</label>
|
||||
<input type="text" v-model="prefix" placeholder="assets" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>版本ID</label>
|
||||
<input type="number" v-model.number="versionId" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="handleUploadList" :disabled="loading">开始处理</button>
|
||||
</div>
|
||||
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||
<div v-if="result" class="result-area">
|
||||
<h4>处理完成</h4>
|
||||
<div class="actions" style="margin-top:8px;">
|
||||
<button class="btn-secondary" @click="showRaw = !showRaw">{{ showRaw ? '隐藏返回JSON' : '显示返回JSON' }}</button>
|
||||
<button class="btn-secondary" v-if="result.import" @click="sendToCms">发送到 CMS</button>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">文件数:</span>
|
||||
<span>{{ result.count }}</span>
|
||||
</div>
|
||||
<div class="files">
|
||||
<div class="file" v-for="(f, i) in result.files" :key="i">
|
||||
<div class="file-row">
|
||||
<span class="label">源文件:</span>
|
||||
<span>{{ f.source }}</span>
|
||||
</div>
|
||||
<div class="file-row" v-if="(f.minio_presigned_url || f.minio_url)">
|
||||
<span class="label">打开:</span>
|
||||
<a :href="f.minio_presigned_url || f.minio_url" target="_blank">查看</a>
|
||||
</div>
|
||||
<div class="file-row" v-if="f.minio_url">
|
||||
<span class="label">MinIO:</span>
|
||||
<a :href="f.minio_url" target="_blank">{{ f.minio_url }}</a>
|
||||
</div>
|
||||
<div class="file-row" v-if="f.minio_presigned_url">
|
||||
<span class="label">临时下载:</span>
|
||||
<a :href="f.minio_presigned_url" target="_blank">下载链接</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item" v-if="result.import">
|
||||
<span class="label">导入JSON:</span>
|
||||
<span>{{ JSON.stringify(result.import) }}</span>
|
||||
</div>
|
||||
<pre v-if="showRaw" class="json-view">{{ JSON.stringify(result, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 12px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #6b7280;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
label {
|
||||
min-width: 120px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.json-view {
|
||||
margin-top: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #dc2626;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.result-area {
|
||||
margin-top: 24px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.files {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
494
frontend/src/components/ConfigModal.vue
Normal file
494
frontend/src/components/ConfigModal.vue
Normal file
@@ -0,0 +1,494 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { setMinioConfig, testMinioConfig, listProfiles, saveProfile, loadProfile, getConfigSnapshot, type MinioConfig, setApiBase, createBucket, checkServerTime, syncServerTime } from '../services/api'
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const config = reactive<MinioConfig>({
|
||||
endpoint: '',
|
||||
public: '',
|
||||
access: '',
|
||||
secret: '',
|
||||
bucket: 'docs',
|
||||
secure: false,
|
||||
prefix: 'assets',
|
||||
store_final: true,
|
||||
public_read: true
|
||||
})
|
||||
|
||||
const statusMsg = ref('未验证')
|
||||
const statusColor = ref('#6b7280')
|
||||
const showSecret = ref(false)
|
||||
const secretType = computed(() => (showSecret.value ? 'text' : 'password'))
|
||||
const profiles = ref<string[]>([])
|
||||
const selectedProfile = ref('')
|
||||
const saveName = ref('')
|
||||
const apiBase = ref('')
|
||||
const timeMsg = ref('未校准')
|
||||
const timeColor = ref('#6b7280')
|
||||
const syncing = ref(false)
|
||||
|
||||
function toBool(v: any): boolean {
|
||||
const s = String(v ?? '').toLowerCase()
|
||||
return s === '1' || s === 'true' || s === 'yes' || s === 'on'
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
statusMsg.value = '正在测试...'
|
||||
statusColor.value = '#2563eb'
|
||||
try {
|
||||
const res = await testMinioConfig(config)
|
||||
if (res.ok && res.bucket_exists) {
|
||||
statusMsg.value = '连接正常'
|
||||
statusColor.value = '#059669'
|
||||
} else if (res.ok && !res.bucket_exists) {
|
||||
// 自动尝试创建桶并应用策略
|
||||
const mk = await createBucket(config)
|
||||
if (mk.ok) {
|
||||
// 创建成功后再次验证
|
||||
const ver = await testMinioConfig(config)
|
||||
if (ver.ok && ver.bucket_exists) {
|
||||
statusMsg.value = '桶已创建并应用策略'
|
||||
statusColor.value = '#059669'
|
||||
} else {
|
||||
statusMsg.value = '创建后校验失败(请检查端口、凭据或网络)'
|
||||
statusColor.value = '#dc2626'
|
||||
}
|
||||
} else {
|
||||
// 创建返回失败,仍做一次验证,若已存在则视为成功
|
||||
const ver = await testMinioConfig(config)
|
||||
if (ver.ok && ver.bucket_exists) {
|
||||
statusMsg.value = '桶已存在(创建返回失败但验证通过)'
|
||||
statusColor.value = '#059669'
|
||||
} else {
|
||||
statusMsg.value = `桶不存在(创建失败):${mk.error || '未知错误'}`
|
||||
statusColor.value = '#dc2626'
|
||||
}
|
||||
}
|
||||
} else if (!res.ok && res.error) {
|
||||
statusMsg.value = res.hint ? `错误:${res.error}(${res.hint})` : `错误:${res.error}`
|
||||
statusColor.value = '#dc2626'
|
||||
} else {
|
||||
statusMsg.value = '连接失败'
|
||||
statusColor.value = '#dc2626'
|
||||
}
|
||||
} catch (e) {
|
||||
statusMsg.value = '网络错误'
|
||||
statusColor.value = '#dc2626'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
try {
|
||||
setApiBase(apiBase.value.trim())
|
||||
await setMinioConfig(config)
|
||||
await fetchProfiles()
|
||||
alert('配置已保存!')
|
||||
close()
|
||||
} catch (e) {
|
||||
alert('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function checkTime() {
|
||||
try {
|
||||
timeMsg.value = '正在检查...'
|
||||
timeColor.value = '#2563eb'
|
||||
const r = await checkServerTime(config)
|
||||
if (r.ok) {
|
||||
const d = r.diff_sec ?? null
|
||||
if (d !== null) {
|
||||
timeMsg.value = d === 0 ? '时间一致' : `偏差约 ${d} 秒${r.hint ? '(' + r.hint + ')' : ''}`
|
||||
timeColor.value = d <= 2 ? '#059669' : '#f59e0b'
|
||||
} else {
|
||||
timeMsg.value = r.hint ? `无法获取时间(${r.hint})` : '无法获取时间'
|
||||
timeColor.value = '#dc2626'
|
||||
}
|
||||
} else {
|
||||
timeMsg.value = r.error ? `错误:${r.error}` : '检查失败'
|
||||
timeColor.value = '#dc2626'
|
||||
}
|
||||
} catch {
|
||||
timeMsg.value = '网络错误'
|
||||
timeColor.value = '#dc2626'
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTime() {
|
||||
if (syncing.value) return
|
||||
syncing.value = true
|
||||
timeMsg.value = '正在同步...'
|
||||
timeColor.value = '#2563eb'
|
||||
try {
|
||||
const r = await syncServerTime('auto')
|
||||
const ok = !!(r && r.ok)
|
||||
await checkTime()
|
||||
if (!ok) {
|
||||
alert('时间同步可能需要管理员权限,请手动授予或在服务端执行')
|
||||
}
|
||||
} catch {
|
||||
timeMsg.value = '同步失败'
|
||||
timeColor.value = '#dc2626'
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfiles() {
|
||||
try {
|
||||
const res = await listProfiles()
|
||||
profiles.value = res.profiles || []
|
||||
} catch {
|
||||
profiles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProfile() {
|
||||
if (!selectedProfile.value) return
|
||||
const res = await loadProfile(selectedProfile.value)
|
||||
if (res.ok && res.config && res.config.minio) {
|
||||
const m = res.config.minio
|
||||
config.endpoint = m.endpoint || ''
|
||||
config.public = m.public || ''
|
||||
config.access = m.access || ''
|
||||
config.secret = m.secret || ''
|
||||
config.bucket = m.bucket || ''
|
||||
config.secure = toBool(m.secure)
|
||||
config.prefix = m.prefix || ''
|
||||
config.store_final = toBool(m.store_final)
|
||||
config.public_read = toBool(m.public_read)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAsProfile() {
|
||||
if (!saveName.value.trim()) return
|
||||
await setMinioConfig(config)
|
||||
const res = await saveProfile(saveName.value.trim())
|
||||
if (res.ok) {
|
||||
await fetchProfiles()
|
||||
alert('配置已保存为:' + (res.name || saveName.value))
|
||||
} else {
|
||||
alert('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchProfiles()
|
||||
try {
|
||||
const snap = await getConfigSnapshot()
|
||||
const m = snap.minio || {}
|
||||
config.endpoint = m.endpoint || config.endpoint
|
||||
config.public = m.public || config.public
|
||||
config.access = m.access || config.access
|
||||
config.secret = m.secret || config.secret
|
||||
config.bucket = m.bucket || config.bucket
|
||||
config.secure = toBool(m.secure)
|
||||
config.prefix = m.prefix || config.prefix
|
||||
config.store_final = toBool(m.store_final)
|
||||
config.public_read = toBool(m.public_read)
|
||||
} catch {}
|
||||
try {
|
||||
const v = localStorage.getItem('app.api.base') || ''
|
||||
apiBase.value = v
|
||||
} catch {}
|
||||
try { await checkTime() } catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="modelValue" class="modal-overlay" @click.self="close">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>连接配置</h3>
|
||||
<button class="close-btn" @click="close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<h4 class="section-title">通用配置</h4>
|
||||
<div class="form-row">
|
||||
<label>接口地址</label>
|
||||
<input v-model="apiBase" placeholder="http://127.0.0.1:8000" />
|
||||
</div>
|
||||
<h4 class="section-title">MinIO 配置</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<label>服务地址</label>
|
||||
<input v-model="config.endpoint" placeholder="127.0.0.1:9000" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>公共访问地址</label>
|
||||
<input v-model="config.public" placeholder="http://127.0.0.1:9000" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>访问密钥</label>
|
||||
<input v-model="config.access" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>密钥</label>
|
||||
<div class="password-row">
|
||||
<input v-model="config.secret" :type="secretType" autocomplete="off" />
|
||||
<button type="button" class="btn-secondary" @click="showSecret = !showSecret">{{ showSecret ? '隐藏' : '显示' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>存储桶</label>
|
||||
<input v-model="config.bucket" placeholder="docs" />
|
||||
</div>
|
||||
|
||||
<div class="form-row checkbox-row">
|
||||
<label>
|
||||
<input type="checkbox" v-model="config.secure" />
|
||||
启用 HTTPS
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>对象前缀</label>
|
||||
<input v-model="config.prefix" placeholder="assets" />
|
||||
</div>
|
||||
|
||||
<div class="form-row checkbox-row">
|
||||
<label>
|
||||
<input type="checkbox" v-model="config.store_final" />
|
||||
保存最终文件
|
||||
</label>
|
||||
<label style="margin-left: 16px;">
|
||||
<input type="checkbox" v-model="config.public_read" />
|
||||
桶公开读取
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>已保存的配置</label>
|
||||
<div class="profiles-row">
|
||||
<select v-model="selectedProfile">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="p in profiles" :key="p" :value="p">{{ p }}</option>
|
||||
</select>
|
||||
<button type="button" class="btn-secondary" @click="applyProfile" :disabled="!selectedProfile">应用配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>配置名称</label>
|
||||
<div class="profiles-row">
|
||||
<input v-model="saveName" placeholder="例如:test 或 default" />
|
||||
<button type="button" class="btn-secondary" @click="saveAsProfile" :disabled="!saveName.trim()">保存为配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="status" :style="{ color: statusColor }">{{ statusMsg }}</div>
|
||||
<div class="actions">
|
||||
<button class="btn-cancel" @click="close">取消</button>
|
||||
<button class="btn-primary" @click="testConnection">测试连接</button>
|
||||
<button class="btn-primary" @click="saveConfig">保存配置</button>
|
||||
<button class="btn-secondary" @click="checkTime">检查时间</button>
|
||||
<button class="btn-secondary" @click="syncTime" :disabled="syncing">时间同步</button>
|
||||
<div class="status" :style="{ color: timeColor }">{{ timeMsg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 640px;
|
||||
max-width: 90%;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.form-row input[type="text"],
|
||||
.form-row input[type="password"],
|
||||
.form-row select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.password-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profiles-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 140px;
|
||||
}
|
||||
|
||||
.checkbox-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 8px 16px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 8px 12px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
color: #111827;
|
||||
}
|
||||
.btn-secondary:disabled {
|
||||
background: #f9fafb;
|
||||
border-color: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
.btn-cancel {
|
||||
height: 36px;
|
||||
}
|
||||
.status {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
</style>
|
||||
337
frontend/src/components/DocToMd.vue
Normal file
337
frontend/src/components/DocToMd.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { convertDoc } from '../services/api'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const mode = ref<'url' | 'file'>('url')
|
||||
const sourceUrl = ref('')
|
||||
const file = ref<File | null>(null)
|
||||
const exportFormat = ref('markdown')
|
||||
const saveToServer = ref(true)
|
||||
const filename = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const result = ref<any>(null)
|
||||
const error = ref('')
|
||||
|
||||
const activeTab = ref<'preview' | 'raw' | 'trace'>('preview')
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const selectedFile = target.files?.[0]
|
||||
if (selectedFile) {
|
||||
file.value = selectedFile
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConvert() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
|
||||
const formData = new FormData()
|
||||
if (mode.value === 'url') {
|
||||
if (!sourceUrl.value) {
|
||||
error.value = '请输入链接'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
formData.append('source_url', sourceUrl.value)
|
||||
} else {
|
||||
if (!file.value) {
|
||||
error.value = '请选择文件'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
formData.append('file', file.value)
|
||||
}
|
||||
|
||||
formData.append('export', exportFormat.value)
|
||||
formData.append('save', String(saveToServer.value))
|
||||
if (filename.value) {
|
||||
formData.append('filename', filename.value)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await convertDoc(formData)
|
||||
if (res.code === 0) {
|
||||
result.value = res.data
|
||||
if (!result.value.content) {
|
||||
const tryFetch = async (url?: string) => {
|
||||
if (!url) return false
|
||||
try {
|
||||
const r = await fetch(url)
|
||||
if (r.ok) {
|
||||
result.value.content = await r.text()
|
||||
return true
|
||||
}
|
||||
} catch {}
|
||||
return false
|
||||
}
|
||||
// 优先尝试直链;失败则回退到临时下载链接(桶未公开读取时可用)
|
||||
const ok = await tryFetch(result.value.minio_url)
|
||||
if (!ok) {
|
||||
await tryFetch(result.value.minio_presigned_url)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error.value = res.msg || '转换失败'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = '网络错误'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
const mt = String(result.value?.media_type || '').toLowerCase()
|
||||
const isMd = exportFormat.value === 'markdown' || mt.startsWith('text/markdown')
|
||||
const isHtml = mt.startsWith('text/html')
|
||||
let c = result.value?.content as string | undefined
|
||||
if (!c) return ''
|
||||
if (isMd) {
|
||||
try {
|
||||
c = c.replace(/!\[[^\]]*\]\(([^)]+)\)/g, (m, u) => {
|
||||
try {
|
||||
const url = new URL(u)
|
||||
url.pathname = encodeURI(url.pathname)
|
||||
return m.replace(u, url.toString())
|
||||
} catch {
|
||||
try {
|
||||
const enc = encodeURI(u)
|
||||
return m.replace(u, enc)
|
||||
} catch {
|
||||
return m
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
return marked.parse(c as string)
|
||||
}
|
||||
if (isHtml) return c
|
||||
return c
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h1>DOCX/PDF 转 Markdown</h1>
|
||||
|
||||
<div class="row">
|
||||
<label>输入方式</label>
|
||||
<div class="radio-group">
|
||||
<label><input type="radio" v-model="mode" value="url"> 链接</label>
|
||||
<label><input type="radio" v-model="mode" value="file"> 文件</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="mode === 'url'">
|
||||
<label>链接</label>
|
||||
<input type="text" v-model="sourceUrl" placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="mode === 'file'">
|
||||
<label>文件</label>
|
||||
<input type="file" @change="onFileChange" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>输出格式</label>
|
||||
<select v-model="exportFormat">
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="html">HTML</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="doctags">DocTags</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>选项</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" v-model="saveToServer"> 保存到服务器(MinIO)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>文件名(可选)</label>
|
||||
<input type="text" v-model="filename" placeholder="默认从来源推断" />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="handleConvert" :disabled="loading">
|
||||
{{ loading ? '正在转换...' : '开始转换' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||
|
||||
<div v-if="result" class="result-area">
|
||||
<div class="tabs">
|
||||
<div
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'preview' }"
|
||||
@click="activeTab = 'preview'"
|
||||
>预览</div>
|
||||
<div
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'raw' }"
|
||||
@click="activeTab = 'raw'"
|
||||
>源文本</div>
|
||||
<div
|
||||
class="tab"
|
||||
:class="{ active: activeTab === 'trace' }"
|
||||
@click="activeTab = 'trace'"
|
||||
>过程日志</div>
|
||||
</div>
|
||||
|
||||
<div class="output-container">
|
||||
<div v-if="activeTab === 'preview'" class="preview-content markdown-body" v-html="renderedContent"></div>
|
||||
<div v-if="activeTab === 'raw'" class="raw-content">{{ result.content }}</div>
|
||||
<div v-if="activeTab === 'trace'" class="raw-content">
|
||||
<div v-for="(line, idx) in (result.trace || [])" :key="idx">{{ line }}</div>
|
||||
<div v-if="(result.mappings || []).length">
|
||||
<div>mappings:</div>
|
||||
<div v-for="(m, i) in result.mappings" :key="i">{{ JSON.stringify(m) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links-section" v-if="result.minio_url || result.minio_presigned_url">
|
||||
<h4>文档链接</h4>
|
||||
<div class="link-item" v-if="result.minio_presigned_url">
|
||||
<span class="label">打开:</span>
|
||||
<a :href="result.minio_presigned_url" target="_blank">查看</a>
|
||||
</div>
|
||||
<div class="link-item" v-if="result.minio_url && !result.minio_presigned_url">
|
||||
<span class="label">MinIO 地址:</span>
|
||||
<a :href="result.minio_url" target="_blank">{{ result.minio_url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 12px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
label {
|
||||
min-width: 120px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"], select {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.radio-group, .checkbox-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #dc2626;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.result-area {
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #f9fafb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.output-container {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
background: #fff;
|
||||
overflow: auto;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.raw-content {
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.links-section {
|
||||
margin-top: 16px;
|
||||
background: #f3f4f6;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link-item .label {
|
||||
font-weight: 600;
|
||||
min-width: auto;
|
||||
}
|
||||
</style>
|
||||
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
384
frontend/src/components/MdToDoc.vue
Normal file
384
frontend/src/components/MdToDoc.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { convertMd } from '../services/api'
|
||||
|
||||
const mode = ref<'text' | 'file' | 'url'>('text')
|
||||
const mdText = ref('')
|
||||
const file = ref<File | null>(null)
|
||||
const url = ref('')
|
||||
const targetFormat = ref('pdf')
|
||||
const saveToServer = ref(false)
|
||||
const filename = ref('')
|
||||
|
||||
// Advanced
|
||||
const showAdvanced = ref(false)
|
||||
const toc = ref(true)
|
||||
const headerText = ref('')
|
||||
const footerText = ref('')
|
||||
const cssName = ref('default')
|
||||
const cssText = ref('')
|
||||
const logoUrl = ref('')
|
||||
const logoFile = ref<File | null>(null)
|
||||
const coverUrl = ref('')
|
||||
const coverFile = ref<File | null>(null)
|
||||
const productName = ref('')
|
||||
const documentName = ref('')
|
||||
const productVersion = ref('')
|
||||
const documentVersion = ref('')
|
||||
const copyrightText = ref('')
|
||||
|
||||
const loading = ref(false)
|
||||
const result = ref<any>(null)
|
||||
const downloadUrl = ref('')
|
||||
const error = ref('')
|
||||
|
||||
watch(targetFormat, (val) => {
|
||||
if (val === 'docx' || val === 'pdf') {
|
||||
showAdvanced.value = true
|
||||
} else {
|
||||
showAdvanced.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const selectedFile = target.files?.[0]
|
||||
if (selectedFile) {
|
||||
file.value = selectedFile
|
||||
}
|
||||
}
|
||||
|
||||
function onLogoFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const selectedFile = target.files?.[0]
|
||||
if (selectedFile) {
|
||||
logoFile.value = selectedFile
|
||||
}
|
||||
}
|
||||
|
||||
function onCoverFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const selectedFile = target.files?.[0]
|
||||
if (selectedFile) {
|
||||
coverFile.value = selectedFile
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConvert() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
downloadUrl.value = ''
|
||||
|
||||
const fd = new FormData()
|
||||
if (mode.value === 'text') {
|
||||
if (!mdText.value.trim()) {
|
||||
error.value = '请输入 Markdown 文本'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
fd.append('markdown_text', mdText.value)
|
||||
} else if (mode.value === 'file') {
|
||||
if (!file.value) {
|
||||
error.value = '请选择文件'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
fd.append('md_file', file.value)
|
||||
} else {
|
||||
if (!url.value.trim()) {
|
||||
error.value = '请输入链接'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
fd.append('markdown_url', url.value)
|
||||
}
|
||||
|
||||
fd.append('target', targetFormat.value)
|
||||
if (saveToServer.value) fd.append('save', 'true')
|
||||
if (filename.value) fd.append('filename', filename.value)
|
||||
|
||||
// Advanced params
|
||||
if (showAdvanced.value) {
|
||||
fd.append('toc', String(toc.value))
|
||||
if (headerText.value) fd.append('header_text', headerText.value)
|
||||
if (footerText.value) fd.append('footer_text', footerText.value)
|
||||
if (cssName.value) fd.append('css_name', cssName.value)
|
||||
if (cssText.value) fd.append('css_text', cssText.value)
|
||||
|
||||
if (logoUrl.value) fd.append('logo_url', logoUrl.value)
|
||||
if (logoFile.value) fd.append('logo_file', logoFile.value)
|
||||
|
||||
if (coverUrl.value) fd.append('cover_url', coverUrl.value)
|
||||
if (coverFile.value) fd.append('cover_file', coverFile.value)
|
||||
|
||||
if (productName.value) fd.append('product_name', productName.value)
|
||||
if (documentName.value) fd.append('document_name', documentName.value)
|
||||
if (productVersion.value) fd.append('product_version', productVersion.value)
|
||||
if (documentVersion.value) fd.append('document_version', documentVersion.value)
|
||||
|
||||
if (copyrightText.value) fd.append('copyright_text', copyrightText.value)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await convertMd(fd)
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
if (ct.includes('application/json')) {
|
||||
const json = await res.json()
|
||||
result.value = json
|
||||
downloadUrl.value = json.minio_presigned_url || json.minio_url
|
||||
} else {
|
||||
const blob = await res.blob()
|
||||
downloadUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = '转换失败:' + String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h1>Markdown 转 DOCX/PDF</h1>
|
||||
|
||||
<div class="row">
|
||||
<label>输入方式</label>
|
||||
<div class="radio-group">
|
||||
<label><input type="radio" v-model="mode" value="text"> 文本</label>
|
||||
<label><input type="radio" v-model="mode" value="file"> 文件</label>
|
||||
<label><input type="radio" v-model="mode" value="url"> 链接</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="mode === 'text'">
|
||||
<label>Markdown 内容</label>
|
||||
<textarea v-model="mdText" rows="8" placeholder="# 在此输入 Markdown 内容..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="mode === 'file'">
|
||||
<label>文件</label>
|
||||
<input type="file" accept=".md,.markdown,.txt" @change="onFileChange" />
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="mode === 'url'">
|
||||
<label>链接</label>
|
||||
<input type="text" v-model="url" placeholder="http(s)://..." />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>目标格式</label>
|
||||
<select v-model="targetFormat">
|
||||
<option value="docx">DOCX</option>
|
||||
<option value="pdf">PDF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="advanced-section" v-if="showAdvanced">
|
||||
<div class="row">
|
||||
<label>CSS 模板</label>
|
||||
<select v-model="cssName">
|
||||
<option value="default">Default</option>
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>自定义 CSS</label>
|
||||
<textarea v-model="cssText" rows="6" placeholder="/* Enter custom CSS here */"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>目录</label>
|
||||
<select v-model="toc">
|
||||
<option :value="true">开启</option>
|
||||
<option :value="false">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>页眉文本</label>
|
||||
<input type="text" v-model="headerText" placeholder="e.g. Internal Document" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>页脚文本</label>
|
||||
<input type="text" v-model="footerText" placeholder="e.g. Confidential" />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Logo</label>
|
||||
<div class="input-group">
|
||||
<input type="text" v-model="logoUrl" placeholder="http(s) or /absolute/path" />
|
||||
<input type="file" accept="image/png,image/jpeg,image/svg+xml,image/webp" @change="onLogoFileChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>封面图片</label>
|
||||
<div class="input-group">
|
||||
<input type="text" v-model="coverUrl" placeholder="http(s) or /absolute/path" />
|
||||
<input type="file" accept="image/png,image/jpeg,image/svg+xml,image/webp" @change="onCoverFileChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>封面文字</label>
|
||||
<div class="grid-group">
|
||||
<input type="text" v-model="productName" placeholder="Product Name" />
|
||||
<input type="text" v-model="documentName" placeholder="Document Name" />
|
||||
<input type="text" v-model="productVersion" placeholder="Product Version" />
|
||||
<input type="text" v-model="documentVersion" placeholder="Document Version" />
|
||||
<div class="db-note">此处应从数据库接口自动获取,待接口完成后即可</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>版权信息</label>
|
||||
<input type="text" v-model="copyrightText" placeholder="© Company Name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>选项</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" v-model="saveToServer"> 保存到服务器</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>文件名(可选)</label>
|
||||
<input type="text" v-model="filename" placeholder="默认文档" />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="handleConvert" :disabled="loading">
|
||||
{{ loading ? '正在转换...' : '开始转换' }}
|
||||
</button>
|
||||
<a v-if="downloadUrl" :href="downloadUrl" class="download-btn" download target="_blank">下载文件</a>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||
|
||||
<div v-if="result" class="result-area">
|
||||
<h4>结果</h4>
|
||||
<pre>{{ JSON.stringify(result, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0 0 12px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
label {
|
||||
min-width: 120px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"], select, textarea {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.radio-group, .checkbox-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
button, .download-btn {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #dc2626;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.result-area {
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 16px;
|
||||
background: #f9fafb;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.grid-group {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.db-note {
|
||||
color: red;
|
||||
font-size: 12px;
|
||||
grid-column: 1/-1;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user