feat(submitter): add form validation tips and improve submission checks

1. add real-time form validation prompt component for submission page
2. add URL validation for official website and screenshot format checks
3. refactor form validity calculation and user input status judgment
4. replace mirror switch with configurable base URL, support dev proxy path handling
5. add processing logic for base64 format screenshots
6. optimize history search and form reset logic
This commit is contained in:
2026-07-16 15:56:13 +08:00
parent a8ea7bec34
commit f585be0dcd
2 changed files with 169 additions and 41 deletions
+60 -32
View File
@@ -698,11 +698,18 @@ export function registerSubmitterHandlers(
ipcMain.handle(
"search-history-app",
async (_event, pkgname: string, useMirror = false) => {
async (_event, pkgname: string, baseUrl?: string) => {
try {
const baseUrl = useMirror
? "https://mirrors.sdu.edu.cn/spark-store"
: "https://spk-json.spark-app.store";
let resolvedBaseUrl = baseUrl || "https://erotica.spark-app.store";
// 开发模式下来自 Vite 代理的路径(如 /local_amd64-store),
// 主进程的 fetch() 无法解析相对 URL,需要转为实际地址
if (resolvedBaseUrl.startsWith("/")) {
logger.info(
{ devPath: resolvedBaseUrl },
"[Submitter] Dev proxy path detected, resolving to production URL",
);
resolvedBaseUrl = "https://erotica.spark-app.store";
}
const storeArchs = [
"amd64-store",
"arm64-store",
@@ -716,7 +723,7 @@ export function registerSubmitterHandlers(
// 为每个架构目录获取各自的分类列表(不同目录可能有不同分类,如 apm-extensions 只在 *-apm 下有)
const archCategoriesMap = new Map<string, string[]>();
for (const arch of storeArchs) {
const cats = await fetchCategoriesFromCdn(baseUrl, arch);
const cats = await fetchCategoriesFromCdn(resolvedBaseUrl, arch);
archCategoriesMap.set(arch, cats);
}
@@ -726,8 +733,7 @@ export function registerSubmitterHandlers(
logger.info(
{
pkgname,
useMirror,
baseUrl,
baseUrl: resolvedBaseUrl,
storeArchs,
archCategoryCounts: Array.from(archCategoriesMap.entries()).map(
([a, c]) => ({ arch: a, categoryCount: c.length }),
@@ -741,7 +747,7 @@ export function registerSubmitterHandlers(
for (const arch of storeArchs) {
const categories = archCategoriesMap.get(arch) || FALLBACK_CATEGORIES;
for (const category of categories) {
const url = `${baseUrl}/${arch}/${category}/${pkgname}/app.json`;
const url = `${resolvedBaseUrl}/${arch}/${category}/${pkgname}/app.json`;
logger.info(
{ arch, category, url },
"[Submitter] Starting search request",
@@ -780,7 +786,7 @@ export function registerSubmitterHandlers(
"[Submitter] Found matching item",
);
let iconUrl = json.icons || json.icon || "";
const iconUrl = json.icons || json.icon || "";
let imgs = json.imgUrls || json.imgs || json.img_urls || [];
if (typeof imgs === "string") {
@@ -805,27 +811,6 @@ export function registerSubmitterHandlers(
}
}
if (iconUrl && typeof iconUrl === "string") {
if (useMirror) {
iconUrl = iconUrl.replace(
"spk-json.spark-app.store",
"mirrors.sdu.edu.cn/spark-store",
);
}
}
if (Array.isArray(imgs)) {
imgs = imgs.map((img: string) => {
if (useMirror && typeof img === "string") {
return img.replace(
"spk-json.spark-app.store",
"mirrors.sdu.edu.cn/spark-store",
);
}
return img;
});
}
results.push({
id: json.id || json.Id || 0,
name: json.name || json.Name || "",
@@ -847,11 +832,24 @@ export function registerSubmitterHandlers(
"[Submitter] Added to results",
);
}
} else {
logger.warn(
{
arch,
category,
url,
status: response.status,
statusText: response.statusText,
},
"[Submitter] Non-ok response",
);
}
})
.catch((error) => {
const errMsg =
error instanceof Error ? error.message : String(error);
logger.warn(
{ arch, category, error },
{ arch, category, url, error: errMsg },
"[Submitter] Request failed or exception caught",
);
});
@@ -1275,6 +1273,35 @@ export function registerSubmitterHandlers(
);
continue;
}
} else if (screenshot.startsWith("data:")) {
logger.info(
"[Submitter] Screenshot is a Base64 data URL, decoding",
);
const base64Data = screenshot.split(",")[1];
if (!base64Data) {
logger.warn("[Submitter] Invalid screenshot data URL, skipping");
continue;
}
const tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), "spark-store-submitter-"),
);
screenshotFilePath = path.join(tempDir, `screen_${i + 1}.png`);
try {
fs.writeFileSync(
screenshotFilePath,
Buffer.from(base64Data, "base64"),
);
logger.info(
{ screenshotFilePath },
"[Submitter] Screenshot decoded from data URL",
);
} catch (err) {
logger.error(
{ err },
"[Submitter] Failed to decode screenshot data URL",
);
continue;
}
}
if (fs.existsSync(screenshotFilePath)) {
@@ -1313,7 +1340,8 @@ export function registerSubmitterHandlers(
if (
screenshot.startsWith("http://") ||
screenshot.startsWith("https://")
screenshot.startsWith("https://") ||
screenshot.startsWith("data:")
) {
fs.unlinkSync(screenshotFilePath);
fs.rmdirSync(path.dirname(screenshotFilePath));
+109 -9
View File
@@ -384,6 +384,55 @@
</button>
</div>
<!-- 表单校验提示 -->
<div
v-if="!isFormValid && hasUserStartedFilling"
class="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg dark:bg-amber-900/20 dark:border-amber-800"
>
<p
class="text-sm font-medium text-amber-700 dark:text-amber-400 mb-1.5"
>
请完善以下信息
</p>
<ul class="text-xs text-amber-600 dark:text-amber-500 space-y-0.5">
<li v-if="!formData.name.trim()">
<i class="fas fa-times-circle mr-1"></i> 应用名称
</li>
<li v-if="!formData.pkgname.trim()">
<i class="fas fa-times-circle mr-1"></i> 包名
</li>
<li v-if="!formData.version.trim()">
<i class="fas fa-times-circle mr-1"></i> 版本号
</li>
<li v-if="!formData.category.trim()">
<i class="fas fa-times-circle mr-1"></i> 分类
</li>
<li v-if="!formData.debFilePath">
<i class="fas fa-times-circle mr-1"></i> 安装包
</li>
<li v-if="!formData.remark.trim()">
<i class="fas fa-times-circle mr-1"></i> 测试情况
</li>
<li v-if="!formData.website.trim()">
<i class="fas fa-times-circle mr-1"></i> 官网地址
</li>
<li
v-if="
formData.website.trim() && !isValidUrl(formData.website.trim())
"
>
<i class="fas fa-times-circle mr-1"></i> 官网地址格式不正确(需以
http:// 或 https:// 开头)
</li>
<li v-if="!formData.tags.trim()">
<i class="fas fa-times-circle mr-1"></i> 标签
</li>
<li v-if="formData.screenshots.length === 0">
<i class="fas fa-times-circle mr-1"></i> 至少上传一张截图
</li>
</ul>
</div>
<div
v-if="isSubmitting || isPackaging"
class="p-4 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-900/20 dark:border-blue-800"
@@ -652,6 +701,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch } from "vue";
import { APM_STORE_BASE_URL } from "@/global/storeConfig";
interface HistoryArchInfo {
id: number;
@@ -755,14 +805,55 @@ const tagsList = ref<Tag[]>([]);
const selectedTags = ref<Tag[]>([]);
const selectedTagValue = ref("");
const isValidUrl = (url: string): boolean => {
if (!url.trim()) return false;
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
};
const isFormValid = computed(() => {
return (
const hasRequiredFields =
formData.name.trim() &&
formData.pkgname.trim() &&
formData.version.trim() &&
formData.category.trim() &&
formData.debFilePath &&
formData.remark.trim()
formData.remark.trim() &&
formData.website.trim() &&
formData.tags.trim();
if (!hasRequiredFields) return false;
// 官网:必须为有效的 http/https URL
if (!isValidUrl(formData.website.trim())) {
return false;
}
// 截图:至少一张,且每张都是有效的 data URL 或 http/https URL
if (formData.screenshots.length === 0) return false;
for (const screenshot of formData.screenshots) {
if (
!screenshot.startsWith("data:") &&
!screenshot.startsWith("http://") &&
!screenshot.startsWith("https://")
) {
return false;
}
}
return true;
});
const hasUserStartedFilling = computed(() => {
return (
formData.name.trim() ||
formData.pkgname.trim() ||
formData.debFilePath ||
formData.screenshots.length > 0
);
});
@@ -952,8 +1043,6 @@ const selectDebFile = async () => {
}
};
const useMirror = ref(true);
const searchHistoryApp = async () => {
console.log(
"[Submitter] ============== SEARCHING HISTORY INFO ==============",
@@ -962,7 +1051,6 @@ const searchHistoryApp = async () => {
"[Submitter] pkgname is not empty, searching history with:",
formData.pkgname,
);
console.log("[Submitter] Using mirror:", useMirror.value);
console.log(
"[Submitter] Calling IPC: search-history-app with pkgname:",
@@ -971,7 +1059,7 @@ const searchHistoryApp = async () => {
const historyResult = await window.ipcRenderer.invoke(
"search-history-app",
formData.pkgname,
useMirror.value,
APM_STORE_BASE_URL,
);
console.log(
"[Submitter] Received history search response:",
@@ -1079,6 +1167,9 @@ const searchHistoryApp = async () => {
historyResult?.message,
);
}
// 未找到历史记录时清空旧数据,避免上一个应用的残留
availableArchs.value = [];
showArchDialog.value = false;
}
};
@@ -1261,9 +1352,12 @@ const selectArch = async (arch: HistoryArchInfo) => {
console.log("[Submitter] Tags loaded from history:", selectedTags.value);
}
const baseUrl = useMirror.value
? `https://mirrors.sdu.edu.cn/spark-store/${arch.store}/${arch.category}/${arch.pkgname}`
: `https://spk-json.spark-app.store/${arch.store}/${arch.category}/${arch.pkgname}`;
// 开发模式下来自 Vite 代理的路径(如 /local_amd64-store),
// 后端 fetch/fs 无法处理,需要解析为实际 CDN 地址
const resolvedBaseUrl = APM_STORE_BASE_URL.startsWith("/")
? "https://erotica.spark-app.store"
: APM_STORE_BASE_URL;
const baseUrl = `${resolvedBaseUrl}/${arch.store}/${arch.category}/${arch.pkgname}`;
console.log(
"[Submitter] Building icon and screenshot URLs with baseUrl:",
@@ -1391,6 +1485,12 @@ const resetForm = () => {
uploadStage.value = "";
uploadStageMessage.value = "";
uploadStages.value = [];
selectedTags.value = [];
selectedTagValue.value = "";
availableArchs.value = [];
showArchDialog.value = false;
currentDebArch.value = "";
isSearchingHistory.value = false;
};
const closeSubmitSuccessModal = () => {