Import project files
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1454
frontend/package-lock.json
generated
Normal file
1454
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^17.0.1",
|
||||
"vue": "^3.5.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
101
frontend/src/App.vue
Normal file
101
frontend/src/App.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import DocToMd from './components/DocToMd.vue'
|
||||
import BatchProcess from './components/BatchProcess.vue'
|
||||
import MdToDoc from './components/MdToDoc.vue'
|
||||
import ConfigModal from './components/ConfigModal.vue'
|
||||
|
||||
const showConfig = ref(false)
|
||||
const activePage = ref<'doc-to-md' | 'batch' | 'md-to-doc'>('doc-to-md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="hero">
|
||||
<div class="hero-title">FunMD文档处理接口</div>
|
||||
<button class="hero-btn" @click="showConfig = true">数据库配置</button>
|
||||
</div>
|
||||
|
||||
<div class="top-tabs">
|
||||
<div class="top-tab" :class="{active: activePage === 'doc-to-md'}" @click="activePage = 'doc-to-md'">DOCX/PDF 转 Markdown</div>
|
||||
<div class="top-tab" :class="{active: activePage === 'batch'}" @click="activePage = 'batch'">批量处理</div>
|
||||
<div class="top-tab" :class="{active: activePage === 'md-to-doc'}" @click="activePage = 'md-to-doc'">Markdown 转 DOCX/PDF</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<DocToMd v-if="activePage === 'doc-to-md'" />
|
||||
<BatchProcess v-if="activePage === 'batch'" />
|
||||
<MdToDoc v-if="activePage === 'md-to-doc'" />
|
||||
</div>
|
||||
|
||||
<ConfigModal v-model="showConfig" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: #f6f7f9;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
margin-top: 8px;
|
||||
background: linear-gradient(90deg, #1d4ed8, #2563eb);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 10px rgba(29, 78, 216, 0.25);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.top-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
.top-tab {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
background: #f9fafb;
|
||||
font-size: 14px;
|
||||
}
|
||||
.top-tab.active {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
</style>
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
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>
|
||||
5
frontend/src/main.ts
Normal file
5
frontend/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
305
frontend/src/services/api.ts
Normal file
305
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
export interface ConvertResponse {
|
||||
code: number
|
||||
msg: string
|
||||
data: {
|
||||
encoding?: string
|
||||
content?: string
|
||||
name?: string
|
||||
minio_url?: string
|
||||
minio_presigned_url?: string
|
||||
export?: string
|
||||
media_type?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArchiveResponse {
|
||||
code: number
|
||||
msg: string
|
||||
data: {
|
||||
count: number
|
||||
files: Array<{ source: string, minio_url?: string, minio_presigned_url?: string, object_name?: string, size?: number }>
|
||||
import?: { versionId: number, tree: any[] }
|
||||
}
|
||||
}
|
||||
|
||||
export interface MinioConfig {
|
||||
endpoint: string
|
||||
public?: string
|
||||
access: string
|
||||
secret: string
|
||||
bucket: string
|
||||
secure?: boolean
|
||||
prefix?: string
|
||||
store_final?: boolean
|
||||
public_read?: boolean
|
||||
}
|
||||
|
||||
const API_BASE = '/api'
|
||||
const CONFIG_BASE = '/config'
|
||||
const API_BASE_KEY = 'app.api.base'
|
||||
const CMS_BASE_KEY = 'cms.api.base'
|
||||
const CMS_TOKEN_KEY = 'cms.api.token'
|
||||
|
||||
function normalizeApiBase(v: string): string {
|
||||
let s = String(v || '').trim()
|
||||
if (!s) return ''
|
||||
if (s.startsWith('//')) s = s.slice(2)
|
||||
if (s.startsWith('/')) s = s.slice(1)
|
||||
if (!/^https?:\/\//i.test(s)) s = `http://${s}`
|
||||
return s.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function setApiBase(v: string) {
|
||||
try { localStorage.setItem(API_BASE_KEY, normalizeApiBase(v)) } catch {}
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
try {
|
||||
const ls = normalizeApiBase(localStorage.getItem(API_BASE_KEY) || '')
|
||||
const env = normalizeApiBase((import.meta as any)?.env?.VITE_API_BASE_URL || '')
|
||||
if (ls) {
|
||||
console.debug('[API] using localStorage base:', ls)
|
||||
return ls
|
||||
}
|
||||
if (env) {
|
||||
console.debug('[API] using env base:', env)
|
||||
return env
|
||||
}
|
||||
// No auto-fallback: use same-origin relative paths when not configured
|
||||
return ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
const b = (base || '').replace(/\/+$/, '')
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return `${b}${p}`
|
||||
}
|
||||
|
||||
function apiFetch(path: string, init?: RequestInit) {
|
||||
const b = baseUrl()
|
||||
const url = b ? joinUrl(b, path) : path
|
||||
console.debug('[API] fetch:', url)
|
||||
return fetch(url, init)
|
||||
}
|
||||
|
||||
function normalizeEndpoint(ep: string): string {
|
||||
let s = String(ep || '').trim()
|
||||
if (!s) return ''
|
||||
try {
|
||||
const hasScheme = /^https?:\/\//i.test(s)
|
||||
if (hasScheme) {
|
||||
const u = new URL(s)
|
||||
s = u.host
|
||||
}
|
||||
const first = s.split('/')[0] || ''
|
||||
s = first
|
||||
} catch {
|
||||
const first = s.split('/')[0] || ''
|
||||
s = first
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
export async function convertDoc(formData: FormData): Promise<ConvertResponse> {
|
||||
const res = await apiFetch(`${API_BASE}/convert`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function uploadArchive(formData: FormData): Promise<ArchiveResponse> {
|
||||
const res = await apiFetch(`${API_BASE}/upload-archive`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function setMinioConfig(config: MinioConfig): Promise<{ ok: boolean }> {
|
||||
const formData = new FormData()
|
||||
Object.entries(config).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
const v = key === 'endpoint' ? normalizeEndpoint(String(value)) : String(value)
|
||||
formData.append(key, v)
|
||||
}
|
||||
})
|
||||
|
||||
const res = await apiFetch(`${CONFIG_BASE}/minio`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function testMinioConfig(config: MinioConfig): Promise<{ ok: boolean, connected: boolean, bucket_exists: boolean, error?: string, created?: boolean, hint?: string }> {
|
||||
const formData = new FormData()
|
||||
Object.entries(config).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
const v = key === 'endpoint' ? normalizeEndpoint(String(value)) : String(value)
|
||||
formData.append(key, v)
|
||||
}
|
||||
})
|
||||
formData.append('create_if_missing', 'true')
|
||||
|
||||
const res = await apiFetch(`${CONFIG_BASE}/minio/test`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createBucket(config: MinioConfig): Promise<{ ok: boolean, bucket_exists?: boolean, error?: string, hint?: string }> {
|
||||
const formData = new FormData()
|
||||
formData.append('endpoint', normalizeEndpoint(String(config.endpoint)))
|
||||
formData.append('access', String(config.access))
|
||||
formData.append('secret', String(config.secret))
|
||||
formData.append('bucket', String(config.bucket))
|
||||
if (config.secure !== undefined) formData.append('secure', String(config.secure))
|
||||
if (config.public_read !== undefined) formData.append('public_read', String(config.public_read))
|
||||
const res = await apiFetch(`/config/minio/create-bucket`, { method: 'POST', body: formData })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function convertMd(formData: FormData): Promise<Response> {
|
||||
return apiFetch(`/md/convert`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
}
|
||||
|
||||
export async function convertFolder(folderPath: string, prefix?: string): Promise<{ ok: boolean, count: number, files: any[] }> {
|
||||
const form = new FormData()
|
||||
form.append('folder_path', folderPath)
|
||||
if (prefix) form.append('prefix', prefix)
|
||||
const res = await apiFetch(`/md/convert-folder`, { method: 'POST', body: form })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function listProfiles(): Promise<{ ok: boolean, profiles: string[] }> {
|
||||
try {
|
||||
const res = await apiFetch(`/config/profiles`)
|
||||
try {
|
||||
return await res.json()
|
||||
} catch {
|
||||
return { ok: false, profiles: [] }
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, profiles: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function stageArchive(file: File, prefix?: string): Promise<{ code: number, msg: string, data: { id: string, name: string, size: number } }> {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (prefix) fd.append('prefix', prefix)
|
||||
const res = await apiFetch(`/api/archive/stage`, { method: 'POST', body: fd })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function processArchive(id: string, prefix?: string, versionId?: number): Promise<ArchiveResponse> {
|
||||
const fd = new FormData()
|
||||
fd.append('id', id)
|
||||
if (prefix) fd.append('prefix', prefix)
|
||||
if (versionId !== undefined) fd.append('versionId', String(versionId))
|
||||
const res = await apiFetch(`/api/archive/process`, { method: 'POST', body: fd })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function uploadList(file: File, prefix?: string, versionId?: number): Promise<ArchiveResponse> {
|
||||
const fd = new FormData()
|
||||
fd.append('list_file', file)
|
||||
if (prefix) fd.append('prefix', prefix)
|
||||
if (versionId !== undefined) fd.append('versionId', String(versionId))
|
||||
const res = await apiFetch(`/api/upload-list`, { method: 'POST', body: fd })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function cmsBaseUrl(): string {
|
||||
try {
|
||||
const val = localStorage.getItem(CMS_BASE_KEY) || ''
|
||||
return normalizeApiBase(val)
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
export function setCmsConfig(base?: string, token?: string) {
|
||||
try {
|
||||
if (base !== undefined) localStorage.setItem(CMS_BASE_KEY, normalizeApiBase(base))
|
||||
if (token !== undefined) localStorage.setItem(CMS_TOKEN_KEY, String(token))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function sendImportToCms(payload: any): Promise<{ ok: boolean, status?: number, error?: string }> {
|
||||
const base = cmsBaseUrl()
|
||||
if (!base) return { ok: false, error: '未配置 CMS 接口地址' }
|
||||
const url = joinUrl(base, '/cms/api/v1/document/directory/import')
|
||||
const token = (localStorage.getItem(CMS_TOKEN_KEY) || '').trim()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) })
|
||||
if (!res.ok) return { ok: false, status: res.status, error: `HTTP ${res.status}` }
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export async function saveProfile(name: string): Promise<{ ok: boolean, name?: string }> {
|
||||
const form = new FormData()
|
||||
form.append('name', name)
|
||||
const res = await apiFetch(`/config/save_profile`, { method: 'POST', body: form })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function loadProfile(name: string): Promise<{ ok: boolean, config?: any }> {
|
||||
const res = await apiFetch(`/config/load_profile?name=${encodeURIComponent(name)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getConfigSnapshot(): Promise<{ minio: MinioConfig, db: Record<string, any> }> {
|
||||
const res = await apiFetch(`/config`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function checkServerTime(config?: Partial<MinioConfig>): Promise<{ ok: boolean, diff_sec?: number, server_time?: string, local_time?: string, hint?: string, error?: string }>{
|
||||
try {
|
||||
const ep = config?.endpoint ? normalizeEndpoint(String(config?.endpoint)) : ''
|
||||
const pub = String(config?.public || '').trim()
|
||||
const sec = config?.secure !== undefined ? String(!!config?.secure) : ''
|
||||
const qs: string[] = []
|
||||
if (ep) qs.push(`endpoint=${encodeURIComponent(ep)}`)
|
||||
if (pub) qs.push(`public=${encodeURIComponent(pub)}`)
|
||||
if (sec) qs.push(`secure=${encodeURIComponent(sec)}`)
|
||||
const q = qs.length ? `?${qs.join('&')}` : ''
|
||||
let res = await apiFetch(`/system/time/check${q}`)
|
||||
if (res.ok) {
|
||||
try { return await res.json() } catch {}
|
||||
}
|
||||
res = await apiFetch(`/api/system/time/check${q}`)
|
||||
if (res.ok) {
|
||||
try { return await res.json() } catch {}
|
||||
}
|
||||
return { ok: false, error: `HTTP ${res.status}` }
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: 'NETWORK' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncServerTime(method?: string, ntpServer?: string): Promise<{ ok: boolean, result?: any, check?: any }>{
|
||||
const fd = new FormData()
|
||||
if (method) fd.append('method', method)
|
||||
if (ntpServer) fd.append('ntp_server', ntpServer)
|
||||
try {
|
||||
let res = await apiFetch(`/system/time/sync`, { method: 'POST', body: fd })
|
||||
if (res.ok) {
|
||||
try { return await res.json() } catch {}
|
||||
}
|
||||
res = await apiFetch(`/api/system/time/sync`, { method: 'POST', body: fd })
|
||||
if (res.ok) {
|
||||
try { return await res.json() } catch {}
|
||||
}
|
||||
return { ok: false }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
72
frontend/src/style.css
Normal file
72
frontend/src/style.css
Normal file
@@ -0,0 +1,72 @@
|
||||
:root {
|
||||
font-family: -apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: light;
|
||||
color: #111827;
|
||||
background-color: #ffffff;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #2563eb;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
background: #ffffff;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
input::placeholder, textarea::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
14
frontend/tests/check_frontend_prd.mjs
Normal file
14
frontend/tests/check_frontend_prd.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const p = path.resolve(process.cwd(), 'frontend/src/components/DocToMd.vue')
|
||||
const s = fs.readFileSync(p, 'utf-8')
|
||||
if (!s.includes('const saveToServer = ref(true)')) {
|
||||
console.error('saveToServer 默认未设置为 true')
|
||||
process.exit(1)
|
||||
}
|
||||
if (!s.includes("mt.startsWith('text/markdown')")) {
|
||||
console.error('renderedContent 未按 media_type 判断 Markdown')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('前端源码检查通过')
|
||||
16
frontend/tsconfig.app.json
Normal file
16
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
47
frontend/vite.config.ts
Normal file
47
frontend/vite.config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
const p = proxy as any
|
||||
p.timeout = 120000
|
||||
p.proxyTimeout = 120000
|
||||
},
|
||||
},
|
||||
'/config': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
const p = proxy as any
|
||||
p.timeout = 120000
|
||||
p.proxyTimeout = 120000
|
||||
},
|
||||
},
|
||||
'/md': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
const p = proxy as any
|
||||
p.timeout = 120000
|
||||
p.proxyTimeout = 120000
|
||||
},
|
||||
},
|
||||
'/refresh.js': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
const p = proxy as any
|
||||
p.timeout = 120000
|
||||
p.proxyTimeout = 120000
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user