20260610173132

This commit is contained in:
oneao committed 2026-06-10 17:31:34 +08:00
1 parent 65685d1a6a
commit 6cb198203d
56 files changed
+4265 -1210

No files matched your search

+425
View File
@@ -0,0 +1,425 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { recognizeDocument, type UploadedFile, uploadFile } from "./api/file";
type PreviewType = "image" | "pdf" | "unsupported";
type DocumentCategory = "customs_declaration" | "booking_note";
const fileInputRef = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const uploadedFile = ref<UploadedFile | null>(null);
const selectedDocumentCategory = ref<DocumentCategory | null>(null);
const recognitionResult = ref<Record<string, unknown> | null>(null);
const uploadError = ref("");
const isUploading = ref(false);
const isRecognizing = ref(false);
const documentCategoryOptions = [
{
label: "报关单",
value: "customs_declaration",
},
{
label: "托书",
value: "booking_note",
},
] satisfies Array<{ label: string; value: DocumentCategory }>;
const selectedDocumentCategoryLabel = computed(() => {
return documentCategoryOptions.find((item) => item.value === selectedDocumentCategory.value)?.label || "";
});
const previewType = computed<PreviewType>(() => {
const file = uploadedFile.value;
if (!file) {
return "unsupported";
}
if (file.contentType?.startsWith("image/")) {
return "image";
}
if (file.contentType === "application/pdf" || file.fileName.toLowerCase().endsWith(".pdf")) {
return "pdf";
}
return "unsupported";
});
const formattedResult = computed(() => {
if (!recognitionResult.value) {
return "";
}
return JSON.stringify(recognitionResult.value, null, 2);
});
const canRecognize = computed(() => {
return Boolean(uploadedFile.value && selectedDocumentCategory.value) && !isRecognizing.value && !isUploading.value;
});
function openFilePicker() {
fileInputRef.value?.click();
}
function handleCategoryChange() {
recognitionResult.value = null;
}
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) {
return;
}
selectedFile.value = file;
uploadedFile.value = null;
recognitionResult.value = null;
uploadError.value = "";
isUploading.value = true;
isRecognizing.value = false;
try {
uploadedFile.value = await uploadFile(file);
} catch (error) {
selectedFile.value = null;
uploadedFile.value = null;
uploadError.value = error instanceof Error ? error.message : "文件上传失败";
} finally {
isUploading.value = false;
}
}
function formatFileSize(size: number) {
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
async function startRecognition() {
const file = uploadedFile.value;
const category = selectedDocumentCategory.value;
if (!file || !category || isRecognizing.value) {
return;
}
isRecognizing.value = true;
recognitionResult.value = null;
try {
const result = await recognizeDocument({
url: file.url,
type: category,
});
console.log("PP-StructureV3 recognition result:", result);
recognitionResult.value = result as Record<string, unknown>;
} catch (error) {
recognitionResult.value = {
status: "failed",
message: error instanceof Error ? error.message : "识别失败",
url: file.url,
type: category,
};
} finally {
isRecognizing.value = false;
}
}
</script>
<template>
<main class="declaration-demo">
<header class="topbar">
<div class="topbar-actions">
<input
ref="fileInputRef"
class="file-input"
type="file"
accept="application/pdf,image/*"
@change="handleFileChange"
/>
<n-button type="primary" :loading="isUploading" :disabled="isUploading" @click="openFilePicker">
上传文件
</n-button>
<n-select
v-model:value="selectedDocumentCategory"
class="category-select"
clearable
placeholder="选择分类"
:options="documentCategoryOptions"
@update:value="handleCategoryChange"
/>
<n-button
type="info"
:disabled="!canRecognize"
:loading="isRecognizing"
@click="startRecognition"
>
开始识别
</n-button>
</div>
<div class="topbar-title">
<h1>报关单 / 托书识别 Demo</h1>
<p>上传 PDF 或图片,选择分类后在右侧查看模拟识别 JSON。</p>
</div>
</header>
<section class="bottom">
<section class="preview-panel" aria-label="文件预览">
<div class="panel-header">
<div>
<h2>文件预览</h2>
<p v-if="isUploading && selectedFile">
正在上传 {{ selectedFile.name }} · {{ formatFileSize(selectedFile.size) }}
</p>
<p v-else-if="uploadedFile">
{{ uploadedFile.originalName }} · {{ formatFileSize(uploadedFile.size) }}
</p>
<p v-else>支持 PDF、JPG、PNG 等格式</p>
</div>
</div>
<div class="preview-surface">
<n-spin v-if="isUploading" size="medium">
<div class="recognizing-placeholder">正在上传文件...</div>
</n-spin>
<template v-else-if="uploadedFile && previewType === 'image'">
<img class="preview-image" :src="uploadedFile.url" :alt="uploadedFile.originalName" />
</template>
<template v-else-if="uploadedFile && previewType === 'pdf'">
<iframe class="preview-frame" :src="uploadedFile.url" :title="uploadedFile.originalName"></iframe>
</template>
<n-empty
v-else-if="uploadError"
:description="uploadError"
class="empty-state"
/>
<n-empty
v-else-if="!uploadedFile"
description="请先上传报关单或托书文件"
class="empty-state"
/>
<n-empty v-else description="当前文件格式暂不支持预览" class="empty-state" />
</div>
</section>
<section class="result-panel" aria-label="识别内容">
<div class="panel-header">
<div>
<h2>识别内容</h2>
<p>{{ selectedDocumentCategoryLabel || "请选择报关单或托书分类" }} · JSON 结构化结果</p>
</div>
</div>
<div class="result-surface">
<n-spin v-if="isRecognizing" size="medium">
<div class="recognizing-placeholder">正在生成模拟识别结果...</div>
</n-spin>
<pre v-else-if="recognitionResult" class="json-output">{{ formattedResult }}</pre>
<n-empty v-else description="上传文件并选择分类后点击开始识别" class="empty-state" />
</div>
</section>
</section>
</main>
</template>
<style scoped>
.declaration-demo {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f4f6f8;
color: #1f2937;
}
.topbar {
min-height: 76px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 24px;
padding: 14px 24px;
border-bottom: 1px solid #dfe5ec;
background: #ffffff;
}
.topbar-title h1 {
margin: 0;
font-size: 20px;
line-height: 1.3;
font-weight: 700;
}
.topbar-title p,
.panel-header p {
margin: 4px 0 0;
color: #667085;
font-size: 13px;
line-height: 1.5;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.file-input {
display: none;
}
.category-select {
width: 150px;
}
.bottom {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(360px, 1.05fr) minmax(360px, 0.95fr);
gap: 16px;
padding: 16px;
overflow: hidden;
}
.preview-panel,
.result-panel {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid #dfe5ec;
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
.panel-header {
min-height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid #e8edf3;
background: #fbfcfe;
}
.panel-header h2 {
margin: 0;
font-size: 16px;
line-height: 1.4;
font-weight: 700;
}
.preview-surface,
.result-surface {
flex: 1;
min-height: 0;
position: relative;
display: flex;
align-items: stretch;
justify-content: stretch;
background: #f8fafc;
overflow: auto;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: contain;
padding: 16px;
}
.preview-frame {
width: 100%;
height: 100%;
border: 0;
background: #ffffff;
}
.result-surface {
overflow: auto;
}
.json-output {
width: 100%;
min-height: 100%;
margin: 0;
padding: 16px;
background: #0b1220;
color: #dbeafe;
font-family:
"Cascadia Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.empty-state,
.recognizing-placeholder {
width: 100%;
min-height: 240px;
display: flex;
align-items: center;
justify-content: center;
}
.recognizing-placeholder {
color: #667085;
padding: 24px;
}
@media (max-width: 860px) {
.topbar {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.topbar-actions {
width: 100%;
}
.category-select {
width: auto;
flex: 1.2;
}
.topbar-actions :deep(.n-button) {
flex: 1;
}
.bottom {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.preview-panel,
.result-panel {
min-height: 0;
}
}
</style>
+62
View File
@@ -0,0 +1,62 @@
import { createAlova } from "alova";
import adapterFetch from "alova/fetch";
export type UploadedFile = {
originalName: string;
fileName: string;
url: string;
size: number;
contentType: string | null;
};
export type RecognitionRequest = {
url: string;
type: string;
};
export type RecognitionResponse = {
url: string;
type: string;
ppStructureResult: unknown;
};
const defaultApiBaseUrl = "http://192.168.1.137:8082";
function normalizeBaseUrl(baseUrl: string | undefined) {
const value = baseUrl?.trim() || defaultApiBaseUrl;
return value.endsWith("/") ? value.slice(0, -1) : value;
}
async function readErrorMessage(response: Response) {
const fallback = `请求失败,状态码 ${response.status}`;
try {
const data = await response.json();
return data.detail || data.message || data.error || fallback;
} catch {
return fallback;
}
}
const http = createAlova({
baseURL: normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL),
requestAdapter: adapterFetch(),
responded: async (response) => {
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
return response.json();
},
});
export function uploadFile(file: File) {
const formData = new FormData();
formData.append("file", file);
return http.Post<UploadedFile>("/api/files/upload", formData).send();
}
export function recognizeDocument(data: RecognitionRequest) {
return http.Post<RecognitionResponse>("/api/recognitions/start", data).send();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

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

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from "vue";
import viteLogo from "../assets/vite.svg";
import heroImg from "../assets/hero.png";
import vueLogo from "../assets/vue.svg";
const count = ref(0);
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">Count is {{ count }}</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
import naive from "naive-ui";
createApp(App).use(naive).mount("#app");
+27
View File
@@ -0,0 +1,27 @@
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
min-width: 320px;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
background: #f4f6f8;
}
button,
input,
textarea,
select {
font: inherit;
}