Import project files

This commit is contained in:
2026-01-07 17:18:26 +08:00
parent 7d9fff2c34
commit 0b07e63b76
66 changed files with 11497 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>