更新了update门户站点界面和部分功能
This commit is contained in:
@@ -30,6 +30,7 @@ import SettingsView from "./views/SettingsView.vue";
|
||||
import SourcesView from "./views/SourcesView.vue";
|
||||
|
||||
type LegacyName = "update-info" | "media-types";
|
||||
type ToastState = { message: string; type: "success" | "warn" | "error" };
|
||||
|
||||
type Captcha = {
|
||||
captchaId: string;
|
||||
@@ -54,9 +55,10 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const currentPath = computed(() => normalizeAdminPath(route.path));
|
||||
const loading = ref(false);
|
||||
const toast = ref("");
|
||||
const toast = ref<ToastState | null>(null);
|
||||
const autoRefreshPaused = ref(false);
|
||||
let refreshTimer: number | undefined;
|
||||
let toastTimer: number | undefined;
|
||||
|
||||
const captcha = ref<Captcha | null>(null);
|
||||
const authBootstrap = ref<AuthBootstrap | null>(null);
|
||||
@@ -99,11 +101,20 @@ const sourceDraft = reactive({
|
||||
clientVisible: true,
|
||||
supportedFormats: "[\"json\"]",
|
||||
});
|
||||
const legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null }>>({
|
||||
"update-info": { raw: "", note: "", preview: null },
|
||||
"media-types": { raw: "", note: "", preview: null },
|
||||
const legacyDrafts = reactive<Record<LegacyName, { raw: string; note: string; preview: any | null; tab: "form" | "raw" | "preview" | "history"; form: any }>>({
|
||||
"update-info": { raw: "", note: "", preview: null, tab: "form", form: {} },
|
||||
"media-types": { raw: "", note: "", preview: null, tab: "form", form: { categories: [] } },
|
||||
});
|
||||
const noticeDraft = reactive({ version: "", raw: "", note: "", preview: null as any });
|
||||
const uploadDraft = reactive({
|
||||
file: null as File | null,
|
||||
version: "",
|
||||
platform: "windows",
|
||||
arch: "x64",
|
||||
channel: "stable",
|
||||
notes: "",
|
||||
updateManifest: true,
|
||||
});
|
||||
|
||||
const routes: RouteItem[] = [
|
||||
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
||||
@@ -151,7 +162,7 @@ const clientCalls = computed(() => dashboard.value?.clientCalls || []);
|
||||
const releasePackages = computed(() => releases.value?.packages || []);
|
||||
const sourceCategories = computed(() => sources.value?.categories || []);
|
||||
const visibleEndpointCount = computed(() => endpoints.value.filter((item) => item.enabled && item.clientVisible).length);
|
||||
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => endpointStatus(item) === "ok").length);
|
||||
const healthyEndpointCount = computed(() => endpoints.value.filter((item) => ["ok", "redirected"].includes(endpointStatus(item))).length);
|
||||
const latestNotice = computed(() => releaseNotices.value[0] || null);
|
||||
const activeLegacyLabel = computed(() => activeLegacyName.value === "media-types" ? "media-types.json" : "update-info.json");
|
||||
|
||||
@@ -176,19 +187,25 @@ const heartbeatOption = computed(() => ({
|
||||
],
|
||||
}));
|
||||
|
||||
const healthOption = computed(() => ({
|
||||
tooltip: { trigger: "item" },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
name: "接口健康",
|
||||
type: "pie",
|
||||
radius: ["48%", "72%"],
|
||||
data: objectEntries(sourceHealth.value),
|
||||
color: ["#16a34a", "#f59e0b", "#dc2626", "#64748b"],
|
||||
},
|
||||
],
|
||||
}));
|
||||
const healthOption = computed(() => {
|
||||
const data = healthStatusOrder.map((item) => ({
|
||||
name: item.label,
|
||||
value: Number(sourceHealth.value?.[item.key] || 0),
|
||||
itemStyle: { color: item.color },
|
||||
})).filter((item) => item.value > 0);
|
||||
return {
|
||||
tooltip: { trigger: "item" },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
name: "接口健康",
|
||||
type: "pie",
|
||||
radius: ["48%", "72%"],
|
||||
data: data.length ? data : [{ name: "暂无数据", value: 1, itemStyle: { color: "#cbd5e1" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const feedbackOption = computed(() => ({
|
||||
tooltip: { trigger: "axis" },
|
||||
@@ -200,7 +217,7 @@ const feedbackOption = computed(() => ({
|
||||
|
||||
const availabilityOption = computed(() => {
|
||||
const total = Number(kpis.value.sourceTotal || 0);
|
||||
const ok = Number(sourceHealth.value.ok || 0);
|
||||
const ok = Number(sourceHealth.value.ok || 0) + Number(sourceHealth.value.redirected || 0);
|
||||
const value = total ? Math.round((ok / total) * 100) : 0;
|
||||
return {
|
||||
series: [
|
||||
@@ -217,10 +234,21 @@ const availabilityOption = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const healthStatusOrder = [
|
||||
{ key: "ok", label: "正常", color: "#16a34a" },
|
||||
{ key: "redirected", label: "重定向健康", color: "#f59e0b" },
|
||||
{ key: "degraded", label: "降级", color: "#d97706" },
|
||||
{ key: "error", label: "错误", color: "#dc2626" },
|
||||
{ key: "unknown", label: "未知", color: "#94a3b8" },
|
||||
];
|
||||
|
||||
const viewContext = computed(() => ({
|
||||
activeLegacyLabel: activeLegacyLabel.value,
|
||||
activeLegacyName: activeLegacyName.value,
|
||||
addFeedbackComment,
|
||||
addMediaCategory,
|
||||
addMediaSubcategory,
|
||||
addUpdateMirror,
|
||||
auditLogs: auditLogs.value,
|
||||
autoRefreshPaused: autoRefreshPaused.value,
|
||||
availabilityOption: availabilityOption.value,
|
||||
@@ -254,11 +282,13 @@ const viewContext = computed(() => ({
|
||||
loadFeedbacks,
|
||||
navigate,
|
||||
noticeDraft,
|
||||
onPackageSelected,
|
||||
openFeedback,
|
||||
openNotice,
|
||||
passwordForm,
|
||||
pretty,
|
||||
previewLegacySync,
|
||||
removeItem,
|
||||
quickActions,
|
||||
releaseNotices: releaseNotices.value,
|
||||
releasePackages: releasePackages.value,
|
||||
@@ -278,6 +308,11 @@ const viewContext = computed(() => ({
|
||||
syncDatabase,
|
||||
testDatabase,
|
||||
toggleAutoRefresh,
|
||||
updateLegacyRawFromForm,
|
||||
uploadDraft,
|
||||
uploadPackage,
|
||||
auditMessage,
|
||||
auditTypeLabel,
|
||||
validateLegacy,
|
||||
validateNotice,
|
||||
visibleEndpointCount: visibleEndpointCount.value,
|
||||
@@ -285,7 +320,7 @@ const viewContext = computed(() => ({
|
||||
|
||||
async function api<T>(target: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("Content-Type") && init.body) headers.set("Content-Type", "application/json");
|
||||
if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
|
||||
if (csrf.value) headers.set("X-CSRF-Token", csrf.value);
|
||||
const res = await fetch(target, { ...init, headers, credentials: "include" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -311,11 +346,12 @@ function toggleAutoRefresh() {
|
||||
autoRefreshPaused.value = !autoRefreshPaused.value;
|
||||
}
|
||||
|
||||
function setToast(message: string) {
|
||||
toast.value = message;
|
||||
window.setTimeout(() => {
|
||||
if (toast.value === message) toast.value = "";
|
||||
}, 4200);
|
||||
function setToast(message: string, type: ToastState["type"] = "success") {
|
||||
toast.value = { message, type };
|
||||
if (toastTimer) window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => {
|
||||
if (toast.value?.message === message) toast.value = null;
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function guarded(task: () => Promise<void>) {
|
||||
@@ -324,7 +360,7 @@ async function guarded(task: () => Promise<void>) {
|
||||
await task();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.value = message;
|
||||
setToast(message, "error");
|
||||
if (message.includes("Login required") || message.includes("UNAUTHORIZED")) {
|
||||
navigate("/admin/login");
|
||||
}
|
||||
@@ -492,6 +528,38 @@ async function loadLegacy(name: LegacyName) {
|
||||
legacyDocuments[name] = data.document;
|
||||
legacyDrafts[name].raw = data.document.raw || "";
|
||||
legacyDrafts[name].preview = data.document.parsed || null;
|
||||
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
||||
}
|
||||
|
||||
function onPackageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
uploadDraft.file = input.files?.[0] || null;
|
||||
if (uploadDraft.file && !uploadDraft.version) {
|
||||
const version = uploadDraft.file.name.match(/\d+\.\d+\.\d+(?:\.\d+)?/)?.[0];
|
||||
if (version) uploadDraft.version = version;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPackage() {
|
||||
if (!uploadDraft.file) {
|
||||
setToast("请选择要上传的发布包", "warn");
|
||||
return;
|
||||
}
|
||||
await guarded(async () => {
|
||||
const form = new FormData();
|
||||
form.append("file", uploadDraft.file as File);
|
||||
form.append("version", uploadDraft.version);
|
||||
form.append("platform", uploadDraft.platform);
|
||||
form.append("arch", uploadDraft.arch);
|
||||
form.append("channel", uploadDraft.channel);
|
||||
form.append("notes", uploadDraft.notes);
|
||||
form.append("updateManifest", String(uploadDraft.updateManifest));
|
||||
await api("/api/admin/releases/packages", { method: "POST", body: form, headers: {} });
|
||||
uploadDraft.file = null;
|
||||
uploadDraft.notes = "";
|
||||
setToast("发布包已上传并放入下载目录");
|
||||
await loadReleases();
|
||||
});
|
||||
}
|
||||
|
||||
async function validateLegacy(name: LegacyName) {
|
||||
@@ -506,6 +574,7 @@ async function validateLegacy(name: LegacyName) {
|
||||
|
||||
async function saveLegacy(name: LegacyName) {
|
||||
await guarded(async () => {
|
||||
if (legacyDrafts[name].tab === "form") updateLegacyRawFromForm(name);
|
||||
const data = await api<{ document: any }>(`/api/admin/legacy/${name}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ raw: legacyDrafts[name].raw, note: legacyDrafts[name].note }),
|
||||
@@ -513,6 +582,7 @@ async function saveLegacy(name: LegacyName) {
|
||||
legacyDocuments[name] = data.document;
|
||||
legacyDrafts[name].raw = data.document.raw;
|
||||
legacyDrafts[name].preview = data.document.parsed;
|
||||
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
||||
legacyDrafts[name].note = "";
|
||||
setToast("兼容 JSON 已保存并发布到旧路径");
|
||||
});
|
||||
@@ -527,10 +597,110 @@ async function restoreLegacy(name: LegacyName, revisionId: number) {
|
||||
legacyDocuments[name] = data.document;
|
||||
legacyDrafts[name].raw = data.document.raw;
|
||||
legacyDrafts[name].preview = data.document.parsed;
|
||||
legacyDrafts[name].form = makeLegacyForm(name, data.document.parsed || {});
|
||||
setToast("兼容 JSON 已恢复");
|
||||
});
|
||||
}
|
||||
|
||||
function makeLegacyForm(name: LegacyName, parsed: any) {
|
||||
if (name === "media-types") {
|
||||
return {
|
||||
layout_version: parsed.layout_version || "1.0.0",
|
||||
last_updated: parsed.last_updated || "",
|
||||
ui_config: JSON.stringify(parsed.ui_config || {}, null, 2),
|
||||
categories: clone(parsed.categories || []).map((cat: any) => ({
|
||||
id: cat.id || "",
|
||||
name: cat.name || "",
|
||||
enabled: cat.enabled !== false,
|
||||
subcategories: clone(cat.subcategories || []).map((sub: any) => ({
|
||||
id: sub.id || "",
|
||||
name: sub.name || "",
|
||||
description: sub.description || "",
|
||||
api_url: sub.api_url || "",
|
||||
thumbnail_url: sub.thumbnail_url || "",
|
||||
refresh_interval: Number(sub.refresh_interval || 300),
|
||||
supported_formats: Array.isArray(sub.supported_formats) ? sub.supported_formats.join(", ") : "",
|
||||
downloadable: sub.downloadable !== false,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
app_version: parsed.app_version || parsed.version || "",
|
||||
title: parsed.title || "",
|
||||
message: parsed.message || "",
|
||||
message_md: parsed.message_md || "",
|
||||
download_url: parsed.download_url || "",
|
||||
release_notes: parsed.release_notes || "",
|
||||
release_notes_md: parsed.release_notes_md || "",
|
||||
update_notes: JSON.stringify(parsed.update_notes || {}, null, 2),
|
||||
last_update_notes: JSON.stringify(parsed.last_update_notes || {}, null, 2),
|
||||
package_sha256: parsed.package_sha256 || "",
|
||||
package_size: parsed.package_size || "",
|
||||
updated_at: parsed.updated_at || parsed.last_updated || "",
|
||||
};
|
||||
}
|
||||
|
||||
function updateLegacyRawFromForm(name: LegacyName) {
|
||||
const current = parseJSONSafe(legacyDrafts[name].raw, legacyDrafts[name].preview || {});
|
||||
const form = legacyDrafts[name].form || {};
|
||||
if (name === "media-types") {
|
||||
current.layout_version = form.layout_version || "1.0.0";
|
||||
current.last_updated = form.last_updated || new Date().toISOString();
|
||||
current.ui_config = parseJSONSafe(form.ui_config, current.ui_config || {});
|
||||
current.categories = (form.categories || []).map((cat: any) => ({
|
||||
...(findByID(current.categories, cat.id) || {}),
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
enabled: cat.enabled !== false,
|
||||
subcategories: (cat.subcategories || []).map((sub: any) => ({
|
||||
...(findByID((findByID(current.categories, cat.id) || {}).subcategories, sub.id) || {}),
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
description: sub.description,
|
||||
api_url: sub.api_url,
|
||||
thumbnail_url: sub.thumbnail_url,
|
||||
refresh_interval: Number(sub.refresh_interval || 300),
|
||||
supported_formats: splitList(sub.supported_formats),
|
||||
downloadable: sub.downloadable !== false,
|
||||
})),
|
||||
}));
|
||||
} else {
|
||||
for (const key of ["app_version", "title", "message", "message_md", "download_url", "release_notes", "release_notes_md", "package_sha256", "updated_at"]) {
|
||||
if (form[key] !== undefined) current[key] = form[key];
|
||||
}
|
||||
if (form.package_size !== "") current.package_size = Number(form.package_size || 0);
|
||||
current.update_notes = parseJSONSafe(form.update_notes, current.update_notes || {});
|
||||
current.last_update_notes = parseJSONSafe(form.last_update_notes, current.last_update_notes || {});
|
||||
}
|
||||
legacyDrafts[name].raw = JSON.stringify(current, null, 2) + "\n";
|
||||
legacyDrafts[name].preview = current;
|
||||
}
|
||||
|
||||
function addUpdateMirror() {
|
||||
const doc = parseJSONSafe(legacyDrafts["update-info"].raw, legacyDrafts["update-info"].preview || {});
|
||||
const mirrors = Array.isArray(doc.download_mirrors) ? doc.download_mirrors : [];
|
||||
mirrors.push({ id: `mirror-${mirrors.length + 1}`, name: "备用镜像", url: "", type: "direct", enabled: true });
|
||||
doc.download_mirrors = mirrors;
|
||||
legacyDrafts["update-info"].raw = JSON.stringify(doc, null, 2) + "\n";
|
||||
legacyDrafts["update-info"].preview = doc;
|
||||
}
|
||||
|
||||
function addMediaCategory(name: LegacyName) {
|
||||
const form = legacyDrafts[name].form;
|
||||
if (!Array.isArray(form.categories)) form.categories = [];
|
||||
form.categories.push({ id: `category-${form.categories.length + 1}`, name: "新分类", enabled: true, subcategories: [] });
|
||||
}
|
||||
|
||||
function addMediaSubcategory(category: any) {
|
||||
if (!Array.isArray(category.subcategories)) category.subcategories = [];
|
||||
category.subcategories.push({ id: `source-${category.subcategories.length + 1}`, name: "新接口", api_url: "", refresh_interval: 300, supported_formats: "json", downloadable: true });
|
||||
}
|
||||
|
||||
function removeItem(list: any[], index: number) {
|
||||
list.splice(index, 1);
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
const data = await api<{ catalog: any }>("/api/admin/sources");
|
||||
sources.value = data.catalog || { categories: [] };
|
||||
@@ -639,7 +809,7 @@ function endpointStatus(item: any) {
|
||||
function statusTone(status: string) {
|
||||
const value = String(status || "").toLowerCase();
|
||||
if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready"].includes(value)) return "good";
|
||||
if (["degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn";
|
||||
if (["redirected", "degraded", "pending", "processing", "queued", "missing"].includes(value)) return "warn";
|
||||
if (["error", "failed", "closed", "offline"].includes(value)) return "bad";
|
||||
return "neutral";
|
||||
}
|
||||
@@ -651,6 +821,7 @@ function objectEntries(value: Record<string, number>) {
|
||||
function labelStatus(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
ok: "正常",
|
||||
redirected: "重定向健康",
|
||||
error: "错误",
|
||||
degraded: "降级",
|
||||
unknown: "未知",
|
||||
@@ -662,6 +833,35 @@ function labelStatus(value: string) {
|
||||
return labels[value] || value || "未知";
|
||||
}
|
||||
|
||||
function auditTypeLabel(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
"auth.login": "管理员登录",
|
||||
"auth.password_changed": "修改后台密码",
|
||||
"feedback.created": "客户端提交反馈",
|
||||
"feedback.updated": "更新反馈工单",
|
||||
"legacy_json.saved": "保存兼容 JSON",
|
||||
"legacy_json.restored": "恢复兼容 JSON",
|
||||
"legacy_json.seeded": "导入 JSON 基板",
|
||||
"release_notice.saved": "保存版本日志",
|
||||
"release.package_uploaded": "上传发布包",
|
||||
"legacy.sync": "旧项目同步",
|
||||
};
|
||||
return labels[value] || value || "未知操作";
|
||||
}
|
||||
|
||||
function auditMessage(item: any) {
|
||||
const message = String(item?.message || "");
|
||||
const legacy: Record<string, string> = {
|
||||
"Admin login": "管理员登录",
|
||||
"Admin password changed": "后台密码已修改",
|
||||
"Legacy JSON saved": "兼容 JSON 已保存",
|
||||
"Legacy JSON restored": "兼容 JSON 已恢复",
|
||||
"Release notice saved": "版本日志已保存",
|
||||
"Feedback updated": "反馈工单已更新",
|
||||
};
|
||||
return legacy[message] || message || auditTypeLabel(item?.type);
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
@@ -683,6 +883,30 @@ function pretty(value: any) {
|
||||
return JSON.stringify(value || {}, null, 2);
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value ?? null));
|
||||
}
|
||||
|
||||
function parseJSONSafe(value: string, fallback: any) {
|
||||
try {
|
||||
return JSON.parse(value || "{}");
|
||||
} catch {
|
||||
return clone(fallback || {});
|
||||
}
|
||||
}
|
||||
|
||||
function findByID(list: any, id: string) {
|
||||
if (!Array.isArray(list)) return null;
|
||||
return list.find((item) => item?.id === id) || null;
|
||||
}
|
||||
|
||||
function splitList(value: string) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
refreshTimer = window.setInterval(() => {
|
||||
@@ -696,6 +920,10 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="toast" :class="['toast', toast.type]">{{ toast.message }}</div>
|
||||
</Teleport>
|
||||
|
||||
<main v-if="currentPath === '/admin/login'" class="login-shell">
|
||||
<section class="login-panel">
|
||||
<div>
|
||||
@@ -721,7 +949,6 @@ onUnmounted(() => {
|
||||
</label>
|
||||
<button class="btn primary full" type="submit">登录</button>
|
||||
</form>
|
||||
<p v-if="toast" class="notice">{{ toast }}</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -760,8 +987,6 @@ onUnmounted(() => {
|
||||
<button class="btn ghost" @click="load"><RefreshCw :size="16" />刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="toast" class="notice">{{ toast }}</p>
|
||||
|
||||
<DashboardView v-if="currentPath === '/admin/dashboard'" :ctx="viewContext" />
|
||||
<FeedbacksView v-else-if="currentPath === '/admin/feedbacks'" :ctx="viewContext" />
|
||||
<ReleasesView v-else-if="currentPath === '/admin/releases'" :ctx="viewContext" />
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
--bad: #b42318;
|
||||
--bad-bg: #fff0ed;
|
||||
--shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
|
||||
--ease: cubic-bezier(.2,.8,.2,1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -57,7 +58,7 @@ h3 { margin-bottom: 8px; font-size: 15px; }
|
||||
padding: 28px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
@@ -67,7 +68,7 @@ input, textarea, select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 8px 10px;
|
||||
@@ -83,7 +84,7 @@ input:focus, textarea:focus, select:focus {
|
||||
.captcha-button {
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
@@ -96,7 +97,7 @@ input:focus, textarea:focus, select:focus {
|
||||
.btn {
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 8px 12px;
|
||||
@@ -106,9 +107,9 @@ input:focus, textarea:focus, select:focus {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
transition: transform 0.18s var(--ease), background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.btn:hover { border-color: var(--line-strong); background: #f9fafb; }
|
||||
.btn:hover { transform: translateY(-1px); border-color: var(--line-strong); background: #f9fafb; box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07); }
|
||||
.btn.primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.btn.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||
.btn.ghost { background: transparent; }
|
||||
@@ -125,6 +126,34 @@ input:focus, textarea:focus, select:focus {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
min-width: min(420px, calc(100vw - 32px));
|
||||
max-width: calc(100vw - 32px);
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
color: #102033;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
|
||||
backdrop-filter: blur(14px);
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
animation: toastIn 0.18s var(--ease);
|
||||
}
|
||||
.toast.success { border-color: #b7e4ca; background: rgba(232, 247, 239, 0.96); color: var(--good); }
|
||||
.toast.warn { border-color: #f4d38c; background: rgba(255, 247, 230, 0.96); color: var(--warn); }
|
||||
.toast.error { border-color: #f0b8b1; background: rgba(255, 240, 237, 0.96); color: var(--bad); }
|
||||
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translate(-50%, -8px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
.app-shell { min-height: 100dvh; display: grid; grid-template-columns: 260px minmax(0, 1fr); }
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
@@ -139,7 +168,7 @@ input:focus, textarea:focus, select:focus {
|
||||
height: 100dvh;
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 8px; display: grid; place-items: center; background: #111827; color: #fff; }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
|
||||
.brand strong { display: block; }
|
||||
.brand small { display: block; color: var(--muted); margin-top: 2px; }
|
||||
.nav-groups { display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
|
||||
@@ -154,7 +183,7 @@ input:focus, textarea:focus, select:focus {
|
||||
}
|
||||
.nav-group button, .logout {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
@@ -165,7 +194,7 @@ input:focus, textarea:focus, select:focus {
|
||||
font-weight: 800;
|
||||
transition: background-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.nav-group button:hover, .logout:hover { background: #eef4ff; color: var(--primary-dark); }
|
||||
.nav-group button:hover, .logout:hover { transform: translateX(2px); background: #eef4ff; color: var(--primary-dark); }
|
||||
.nav-group button.active { background: var(--primary-soft); color: var(--primary-dark); }
|
||||
.logout { color: #7f1d1d; }
|
||||
|
||||
@@ -179,10 +208,17 @@ input:focus, textarea:focus, select:focus {
|
||||
.metric, .panel {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||
}
|
||||
.metric, .panel, .quick-grid button, .revision-list button, .nested-card {
|
||||
transition: transform 0.2s var(--ease), border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
.metric:hover, .panel:hover, .quick-grid button:hover, .revision-list button:hover, .nested-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.metric { min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.metric span, .metric small { color: var(--muted); }
|
||||
.metric strong { font-size: 26px; overflow-wrap: anywhere; }
|
||||
@@ -195,7 +231,7 @@ input:focus, textarea:focus, select:focus {
|
||||
.quick-grid button {
|
||||
min-height: 112px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
padding: 12px;
|
||||
@@ -229,7 +265,8 @@ table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th, td { border-bottom: 1px solid var(--line); padding: 10px 8px; text-align: left; vertical-align: top; }
|
||||
th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
tr.clickable { cursor: pointer; }
|
||||
tr.clickable:hover td { background: #f8fbff; }
|
||||
tbody tr { transition: background-color 0.18s ease; }
|
||||
tr.clickable:hover td, tbody tr:hover td { background: #f8fbff; }
|
||||
tr.selected td { background: #eef4ff; }
|
||||
|
||||
.badge {
|
||||
@@ -267,7 +304,7 @@ hr { border: 0; border-top: 1px solid var(--line); width: 100%; margin: 12px 0;
|
||||
.compact-editor { min-height: 260px; }
|
||||
details {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
@@ -277,7 +314,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 12px;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
padding: 12px;
|
||||
@@ -289,7 +326,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.revision-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.revision-list button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
@@ -301,6 +338,50 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.kv-grid span { color: var(--muted); }
|
||||
.kv-grid strong { overflow-wrap: anywhere; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.tabs button {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
padding: 7px 12px;
|
||||
font-weight: 900;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.tabs button:hover, .tabs button.active {
|
||||
border-color: rgba(37, 99, 235, 0.28);
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.form-grid .wide, label.wide { grid-column: 1 / -1; }
|
||||
.mini-editor { min-height: 160px; }
|
||||
.nested-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(248, 250, 252, 0.78);
|
||||
padding: 14px;
|
||||
}
|
||||
.nested-card.inner {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.upload-card {
|
||||
background: linear-gradient(135deg, #ffffff, #f8fbff);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.chart-grid, .split, .split.wide-split { grid-template-columns: 1fr; }
|
||||
@@ -314,6 +395,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
.workspace { padding: 16px; }
|
||||
.topbar, .section-head { align-items: stretch; flex-direction: column; }
|
||||
.metric-grid, .two-col { grid-template-columns: 1fr; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.quick-grid { grid-template-columns: 1fr; }
|
||||
.captcha-row { grid-template-columns: 1fr; }
|
||||
table { min-width: 720px; }
|
||||
@@ -321,5 +403,5 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition: none !important; scroll-behavior: auto !important; }
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ defineProps<{ ctx: any }>();
|
||||
<thead><tr><th>类型</th><th>目标</th><th>信息</th><th>IP</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.auditLogs" :key="item.id">
|
||||
<td>{{ item.type }}</td>
|
||||
<td><span class="badge neutral">{{ ctx.auditTypeLabel(item.type) }}</span></td>
|
||||
<td>{{ item.target }}</td>
|
||||
<td>{{ item.message }}</td>
|
||||
<td>{{ ctx.auditMessage(item) }}</td>
|
||||
<td>{{ item.ip || "-" }}</td>
|
||||
<td>{{ item.createdAt }}</td>
|
||||
</tr>
|
||||
|
||||
@@ -12,7 +12,10 @@ defineProps<{ ctx: any }>();
|
||||
<td class="mono">{{ item.id || item.sourceId }}</td>
|
||||
<td>{{ item.category || item.categoryId }}</td>
|
||||
<td>{{ item.proxyMode }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.endpointStatus(item) }}</span></td>
|
||||
<td>
|
||||
<span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.labelStatus(ctx.endpointStatus(item)) }}</span>
|
||||
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
|
||||
</td>
|
||||
<td>{{ item.cacheSeconds || 0 }}s</td>
|
||||
<td class="hash">{{ item.urlTemplate || item.apiUrl }}</td>
|
||||
<td><button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)">编辑</button></td>
|
||||
|
||||
@@ -1,30 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Save } from "lucide-vue-next";
|
||||
import { CheckCircle2, Plus, Save, Trash2 } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="split wide-split">
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>{{ ctx.activeLegacyLabel }}</h2>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
||||
<button class="btn primary" @click="ctx.saveLegacy(ctx.activeLegacyName)"><Save :size="16" />保存发布</button>
|
||||
</div>
|
||||
<p class="muted">以当前兼容 JSON 为基板,表单保存会合并进原 JSON,未知字段保留。</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.validateLegacy(ctx.activeLegacyName)"><CheckCircle2 :size="16" />校验</button>
|
||||
<button class="btn primary" @click="ctx.saveLegacy(ctx.activeLegacyName)"><Save :size="16" />保存发布</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'form'">可视化表单</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'raw'">Raw JSON</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'preview'">预览</button>
|
||||
<button :class="{ active: ctx.legacyDrafts[ctx.activeLegacyName].tab === 'history' }" @click="ctx.legacyDrafts[ctx.activeLegacyName].tab = 'history'">历史版本</button>
|
||||
</div>
|
||||
|
||||
<section v-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form' && ctx.activeLegacyName === 'update-info'" class="form-grid">
|
||||
<label>版本号<input v-model="ctx.legacyDrafts['update-info'].form.app_version" /></label>
|
||||
<label>标题<input v-model="ctx.legacyDrafts['update-info'].form.title" /></label>
|
||||
<label class="wide">公告<textarea v-model="ctx.legacyDrafts['update-info'].form.message" rows="3"></textarea></label>
|
||||
<label class="wide">下载地址<input v-model="ctx.legacyDrafts['update-info'].form.download_url" /></label>
|
||||
<label>包 SHA256<input v-model="ctx.legacyDrafts['update-info'].form.package_sha256" /></label>
|
||||
<label>包大小<input v-model="ctx.legacyDrafts['update-info'].form.package_size" type="number" /></label>
|
||||
<label class="wide">发布说明<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes" rows="4"></textarea></label>
|
||||
<label class="wide">发布说明 Markdown<textarea v-model="ctx.legacyDrafts['update-info'].form.release_notes_md" rows="5"></textarea></label>
|
||||
<label class="wide">更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
<label class="wide">上次更新说明 JSON<textarea v-model="ctx.legacyDrafts['update-info'].form.last_update_notes" class="code-editor mini-editor"></textarea></label>
|
||||
<div class="wide button-row">
|
||||
<button class="btn ghost" @click="ctx.addUpdateMirror"><Plus :size="16" />新增镜像字段到底稿</button>
|
||||
<button class="btn" @click="ctx.updateLegacyRawFromForm('update-info')">生成预览 JSON</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'form'" class="page-stack">
|
||||
<div class="form-grid">
|
||||
<label>布局版本<input v-model="ctx.legacyDrafts['media-types'].form.layout_version" /></label>
|
||||
<label>更新时间<input v-model="ctx.legacyDrafts['media-types'].form.last_updated" /></label>
|
||||
<label class="wide">UI 配置 JSON<textarea v-model="ctx.legacyDrafts['media-types'].form.ui_config" class="code-editor mini-editor"></textarea></label>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost" @click="ctx.addMediaCategory('media-types')"><Plus :size="16" />新增分类</button>
|
||||
<button class="btn" @click="ctx.updateLegacyRawFromForm('media-types')">生成预览 JSON</button>
|
||||
</div>
|
||||
<section v-for="(cat, cIndex) in ctx.legacyDrafts['media-types'].form.categories" :key="cIndex" class="nested-card">
|
||||
<div class="section-head">
|
||||
<h3>分类 {{ cIndex + 1 }}</h3>
|
||||
<button class="btn ghost compact" @click="ctx.removeItem(ctx.legacyDrafts['media-types'].form.categories, cIndex)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>ID<input v-model="cat.id" /></label>
|
||||
<label>名称<input v-model="cat.name" /></label>
|
||||
<label class="checkbox"><input v-model="cat.enabled" type="checkbox" />启用分类</label>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.addMediaSubcategory(cat)"><Plus :size="14" />新增子接口</button>
|
||||
</div>
|
||||
<section v-for="(sub, sIndex) in cat.subcategories" :key="sIndex" class="nested-card inner">
|
||||
<div class="section-head">
|
||||
<h3>{{ sub.name || "子接口" }}</h3>
|
||||
<button class="btn ghost compact" @click="ctx.removeItem(cat.subcategories, sIndex)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>ID<input v-model="sub.id" /></label>
|
||||
<label>名称<input v-model="sub.name" /></label>
|
||||
<label class="wide">接口 URL<input v-model="sub.api_url" /></label>
|
||||
<label>缩略图<input v-model="sub.thumbnail_url" /></label>
|
||||
<label>刷新间隔<input v-model.number="sub.refresh_interval" type="number" /></label>
|
||||
<label>格式<input v-model="sub.supported_formats" placeholder="json, xml" /></label>
|
||||
<label class="checkbox"><input v-model="sub.downloadable" type="checkbox" />可下载</label>
|
||||
<label class="wide">描述<textarea v-model="sub.description" rows="2"></textarea></label>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'raw'" class="page-stack">
|
||||
<textarea v-model="ctx.legacyDrafts[ctx.activeLegacyName].raw" class="code-editor"></textarea>
|
||||
<label>保存备注<input v-model="ctx.legacyDrafts[ctx.activeLegacyName].note" /></label>
|
||||
</section>
|
||||
<aside class="panel page-stack">
|
||||
<h2>预览与历史</h2>
|
||||
<pre class="json-preview">{{ ctx.pretty(ctx.legacyDrafts[ctx.activeLegacyName].preview) }}</pre>
|
||||
<div class="revision-list">
|
||||
<button v-for="revision in ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []" :key="revision.id" @click="ctx.restoreLegacy(ctx.activeLegacyName, revision.id)">
|
||||
#{{ revision.id }} {{ revision.createdAt }}<small>{{ revision.note || "无备注" }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section v-else-if="ctx.legacyDrafts[ctx.activeLegacyName].tab === 'preview'">
|
||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.legacyDrafts[ctx.activeLegacyName].preview) }}</pre>
|
||||
</section>
|
||||
|
||||
<section v-else class="revision-list">
|
||||
<button v-for="revision in ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []" :key="revision.id" @click="ctx.restoreLegacy(ctx.activeLegacyName, revision.id)">
|
||||
#{{ revision.id }} {{ revision.createdAt }}<small>{{ revision.note || "无备注" }}</small>
|
||||
</button>
|
||||
<div v-if="(ctx.legacyDocuments[ctx.activeLegacyName]?.revisions || []).length === 0" class="empty-state compact">暂无历史版本。</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Save } from "lucide-vue-next";
|
||||
import { CheckCircle2, Save, UploadCloud } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
</script>
|
||||
@@ -9,8 +9,24 @@ defineProps<{ ctx: any }>();
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head">
|
||||
<h2>发布包</h2>
|
||||
<a href="/update-info.json" target="_blank">查看旧版 update-info.json</a>
|
||||
<span class="badge">{{ ctx.releasePackages.length }} 个文件</span>
|
||||
</div>
|
||||
<section class="nested-card upload-card">
|
||||
<div class="section-head">
|
||||
<h3>上传最新版本包</h3>
|
||||
<span class="badge neutral">保存到下载目录</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="wide">安装包<input type="file" accept=".exe,.msix,.appinstaller,.msi,.zip,.7z" @change="ctx.onPackageSelected" /></label>
|
||||
<label>版本号<input v-model="ctx.uploadDraft.version" placeholder="2.0.6.31" /></label>
|
||||
<label>平台<select v-model="ctx.uploadDraft.platform"><option value="windows">Windows</option><option value="linux">Linux</option></select></label>
|
||||
<label>架构<select v-model="ctx.uploadDraft.arch"><option value="x64">x64</option><option value="x86">x86</option><option value="arm64">arm64</option></select></label>
|
||||
<label>通道<select v-model="ctx.uploadDraft.channel"><option value="stable">stable</option><option value="beta">beta</option></select></label>
|
||||
<label class="wide">发布说明<textarea v-model="ctx.uploadDraft.notes" rows="3"></textarea></label>
|
||||
<label class="checkbox wide"><input v-model="ctx.uploadDraft.updateManifest" type="checkbox" />上传后同步更新兼容 update-info.json</label>
|
||||
</div>
|
||||
<button class="btn primary" @click="ctx.uploadPackage"><UploadCloud :size="16" />上传发布包</button>
|
||||
</section>
|
||||
<table>
|
||||
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th>SHA256</th></tr></thead>
|
||||
<tbody>
|
||||
|
||||
Reference in New Issue
Block a user