更新了update门户站点界面和部分功能

This commit is contained in:
QWQLwToo
2026-06-26 14:30:09 +08:00
parent 57f4d94d0a
commit cd2fd435a2
20 changed files with 1128 additions and 168 deletions
+35 -6
View File
@@ -855,24 +855,53 @@ func (s *Store) IsDefaultAdminPassword(ctx context.Context) (bool, error) {
}
func (s *Store) ChangeAdminPassword(ctx context.Context, username, current, next string) error {
_, err := s.ChangeAdminPasswordWithWarning(ctx, username, current, next)
return err
}
func (s *Store) ChangeAdminPasswordWithWarning(ctx context.Context, username, current, next string) (string, error) {
if strings.TrimSpace(next) == "" {
return errors.New("new password is required")
return "", errors.New("new password is required")
}
_, ok, err := s.VerifyAdminPassword(ctx, username, current)
if err != nil {
return err
return "", err
}
if !ok {
return errors.New("current password is invalid")
return "", errors.New("current password is invalid")
}
result, err := s.exec(`UPDATE admin_users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE username = ?`, passwordHash(next), Now(), username)
username = firstNonEmpty(strings.TrimSpace(username), "admin")
hash := passwordHash(next)
now := Now()
if err := s.changeAdminPasswordOn(s.localDB, s.localDialect, username, hash, now, true); err != nil {
return "", err
}
conn, d := s.active()
if conn != nil && conn != s.localDB {
if err := s.changeAdminPasswordOn(conn, d, username, hash, now, false); err != nil {
s.markFailover(err)
return "远端 MySQL 同步失败,密码已持久化到本地 SQLite", nil
}
}
return "", nil
}
func (s *Store) changeAdminPasswordOn(conn *sql.DB, d dialect, username, hash, updatedAt string, insertIfMissing bool) error {
if conn == nil {
return errors.New("database is not available")
}
result, err := conn.Exec(d.rebind(`UPDATE admin_users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE username = ?`), hash, updatedAt, username)
if err != nil {
return err
}
if rows, _ := result.RowsAffected(); rows == 0 {
if rows, _ := result.RowsAffected(); rows > 0 {
return nil
}
if !insertIfMissing {
return errors.New("admin user not found")
}
return nil
_, err = conn.Exec(d.rebind(`INSERT INTO admin_users (username, password_hash, password_changed, created_at, updated_at) VALUES (?, ?, 1, ?, ?)`), username, hash, updatedAt, updatedAt)
return err
}
func (s *Store) InsertFeedback(item Feedback) error {
@@ -368,10 +368,11 @@ func sha256File(path string) string {
}
func safePackageName(name string) (string, error) {
name = strings.TrimSpace(filepath.Base(name))
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
original := strings.TrimSpace(name)
if original == "" || original == "." || original == ".." || strings.ContainsAny(original, `/\`) {
return "", errors.New("invalid filename")
}
name = filepath.Base(original)
lower := strings.ToLower(name)
for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} {
if strings.HasSuffix(lower, suffix) {
@@ -49,8 +49,9 @@ func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) {
DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
BaseURL: "https://update.ymhut.cn",
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
},
}
store, err := db.Open(cfg)
@@ -88,8 +89,9 @@ func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) {
UpdatePublicDir: filepath.Join(dir, "data", "update", "public"),
DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
},
}
store, err := db.Open(cfg)
@@ -29,9 +29,9 @@ type legacyMedia struct {
}
type legacyCategory struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled *bool `json:"enabled"`
ID string `json:"id"`
Name string `json:"name"`
Enabled *bool `json:"enabled"`
Subcategories []legacySubcategory `json:"subcategories"`
}
@@ -43,7 +43,7 @@ type legacySubcategory struct {
ThumbnailURL string `json:"thumbnail_url"`
RefreshInterval int `json:"refresh_interval"`
SupportedFormats []string `json:"supported_formats"`
Downloadable bool `json:"downloadable"`
Downloadable bool `json:"downloadable"`
}
func NewService(cfg *config.Config, store *db.Store) *Service {
@@ -150,19 +150,19 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
var formats []string
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
sub := map[string]any{
"id": item.SourceID,
"name": item.Name,
"description": item.Description,
"api_url": item.APIURL,
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
"thumbnail_url": item.ThumbnailURL,
"method": item.Method,
"proxy_mode": item.ProxyMode,
"proxyMode": item.ProxyMode,
"refresh_interval": item.CheckIntervalSec,
"cacheSeconds": item.CacheSeconds,
"supported_formats": formats,
"downloadable": true,
"id": item.SourceID,
"name": item.Name,
"description": item.Description,
"api_url": item.APIURL,
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
"thumbnail_url": item.ThumbnailURL,
"method": item.Method,
"proxy_mode": item.ProxyMode,
"proxyMode": item.ProxyMode,
"refresh_interval": item.CheckIntervalSec,
"cacheSeconds": item.CacheSeconds,
"supported_formats": formats,
"downloadable": true,
"health": map[string]any{
"status": item.LastStatus,
"latency_ms": item.LastLatencyMS,
@@ -68,8 +68,9 @@ func testStore(t *testing.T) (*config.Config, *db.Store) {
DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
BaseURL: "https://update.ymhut.cn",
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
},
}
store, err := db.Open(cfg)
@@ -294,7 +294,7 @@ func (s *Service) importOldWebhooks(oldDB *sql.DB, result *Result) {
if err := rows.Scan(&id, &name, &event, &status, &attempts, &response, &message, &payload, &createdAt, &finishedAt); err != nil {
continue
}
_ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: "旧反馈 Webhook 记录:" + strings.TrimSpace(event+" "+message), CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())})
_ = s.store.InsertAudit(db.AuditLog{Actor: "legacy", Type: "webhook." + status, Target: name, Message: "旧反馈 Webhook 记录:" + strings.TrimSpace(event+" "+message), CreatedAt: firstNonEmpty(createdAt, finishedAt, db.Now())})
result.Stats["importedRows"]++
}
}
@@ -195,12 +195,17 @@ func (r *router) handleChangePassword(w http.ResponseWriter, req *http.Request)
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
if err := r.store.ChangeAdminPassword(req.Context(), "admin", body.CurrentPassword, body.NewPassword); err != nil {
warning, err := r.store.ChangeAdminPasswordWithWarning(req.Context(), "admin", body.CurrentPassword, body.NewPassword)
if err != nil {
writeError(w, http.StatusBadRequest, "PASSWORD_CHANGE_FAILED", err)
return
}
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "后台密码已修改", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
payload := map[string]any{"ok": true, "isDefaultPassword": false}
if warning != "" {
payload["warning"] = warning
}
writeJSON(w, http.StatusOK, payload)
}
func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) {
+255 -30
View File
@@ -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>
@@ -1,12 +1,9 @@
<script setup lang="ts">
const routes = [
{ path: "/api/client/bootstrap", label: "新版客户端 Bootstrap" },
{ path: "/api/client/releases", label: "新版发布信息" },
{ path: "/api/client/sources", label: "新版接口源目录" },
{ path: "/update-info.json", label: "旧版更新 JSON" },
{ path: "/media-types.json", label: "旧版媒体源 JSON" },
{ path: "/tool-status.json", label: "旧版工具状态" },
{ path: "/modules.json", label: "旧版模块清单" },
const items = [
{ title: "旧版更新能力", body: "客户端继续按原有方式读取更新信息、工具状态、模块清单和下载包。" },
{ title: "旧版媒体源能力", body: "媒体源结构保持旧字段兼容,后台保存后会同步到旧客户端可读结构。" },
{ title: "新版动态配置", body: "新版客户端优先从服务端读取发布、接口源、健康状态和缓存策略,失败时回退旧路径。" },
{ title: "反馈兼容", body: "旧反馈提交和状态查询入口继续保留,后台统一沉淀为反馈工单。" },
];
</script>
@@ -14,16 +11,13 @@ const routes = [
<section class="page-heading">
<p class="eyebrow">Compatibility</p>
<h1>兼容说明</h1>
<p>新旧客户端共用 update.ymhut.cn旧路径和旧 JSON 字段继续保留</p>
<p>新旧客户端共用 update.ymhut.cn门户只展示能力说明具体接口由客户端自动选择</p>
</section>
<section class="panel wide">
<h2>公开路径</h2>
<div class="route-list">
<a v-for="item in routes" :key="item.path" :href="item.path">
<strong>{{ item.path }}</strong>
<span>{{ item.label }}</span>
</a>
</div>
<section class="content-grid">
<article v-for="item in items" :key="item.title" class="panel compat-card">
<h2>{{ item.title }}</h2>
<p>{{ item.body }}</p>
</article>
</section>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Activity, ArrowDownToLine, Database, ExternalLink, HeartPulse, Network, ShieldCheck } from "lucide-vue-next";
import { Activity, ArrowDownToLine, Database, HeartPulse, Network, ShieldCheck } from "lucide-vue-next";
import { usePortalState } from "../state";
const state = usePortalState();
@@ -10,14 +10,12 @@ const state = usePortalState();
<div class="hero-copy">
<p class="eyebrow">update.ymhut.cn</p>
<h1>统一发布反馈与接口源状态门户</h1>
<p>
新版客户端通过 bootstrap 动态获取发布信息版本日志媒体/数据源目录和接口健康状态旧客户端仍可继续访问
update-info.jsonmedia-types.json下载路径和反馈根路径
</p>
<p>统一展示 YMhut Box 的发布状态反馈入口接口源可用性与版本日志新版客户端动态读取服务配置旧客户端兼容能力继续保留</p>
<div class="actions">
<a class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
<a class="button" href="/api/client/bootstrap"><ShieldCheck :size="18" />客户端配置</a>
<RouterLink class="button" to="/compatibility"><ExternalLink :size="18" />兼容路径</RouterLink>
<a v-if="state.downloadUrl.value" class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
<RouterLink v-else class="button primary" to="/releases"><ArrowDownToLine :size="18" />查看发布状态</RouterLink>
<RouterLink class="button" to="/sources"><ShieldCheck :size="18" />查看接口状态</RouterLink>
<RouterLink class="button" to="/compatibility">兼容说明</RouterLink>
</div>
<div class="hero-tags">
<span>Legacy JSON 兼容</span>
@@ -47,7 +45,7 @@ const state = usePortalState();
<section class="content-grid">
<article class="panel">
<div class="section-head"><h2>服务入口</h2><a href="/api/client/bootstrap">Bootstrap <ExternalLink :size="14" /></a></div>
<div class="section-head"><h2>服务入口</h2><span class="badge good">运行中</span></div>
<div class="route-list">
<RouterLink to="/releases"><strong>发布版本</strong><span>下载包版本公告和 update-notice 日志</span></RouterLink>
<RouterLink to="/sources"><strong>接口源健康</strong><span>媒体源数据源和动态客户端接口状态</span></RouterLink>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BookOpenText, ExternalLink } from "lucide-vue-next";
import { BookOpenText } from "lucide-vue-next";
import { usePortalState } from "../state";
const state = usePortalState();
@@ -14,7 +14,7 @@ const state = usePortalState();
<section class="content-grid">
<article class="panel wide">
<div class="section-head"><h2>发布包</h2><a href="/update-info.json">旧版 update-info.json <ExternalLink :size="14" /></a></div>
<div class="section-head"><h2>发布包</h2><span class="badge">{{ state.packages.value.length }} 个可用包</span></div>
<table>
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th></th></tr></thead>
<tbody>
@@ -31,7 +31,7 @@ const state = usePortalState();
</article>
<article class="panel wide">
<div class="section-head"><h2>版本日志</h2><a href="/api/client/notices">Notices API <ExternalLink :size="14" /></a></div>
<div class="section-head"><h2>版本日志</h2><span class="badge good">自动同步</span></div>
<div class="notice-list">
<section v-for="notice in state.notices.value" :key="notice.version" class="notice-card">
<BookOpenText :size="22" />
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CheckCircle2, ExternalLink } from "lucide-vue-next";
import { CheckCircle2 } from "lucide-vue-next";
import { usePortalState } from "../state";
const state = usePortalState();
@@ -13,7 +13,7 @@ const state = usePortalState();
</section>
<section class="panel wide">
<div class="section-head"><h2>接口源可用性</h2><a href="/api/client/sources">Sources API <ExternalLink :size="14" /></a></div>
<div class="section-head"><h2>接口源可用性</h2><span class="badge">{{ state.sourceCount.value }} 个接口源</span></div>
<div v-if="state.categories.value.length" class="source-board">
<section v-for="cat in state.categories.value" :key="cat.id || cat.name" class="source-group">
<div>
@@ -22,11 +22,11 @@ const state = usePortalState();
</div>
<div class="source-list">
<span v-for="src in cat.subcategories || []" :key="src.id || src.sourceId" :class="['badge', state.statusTone(state.sourceStatus(src))]">
<CheckCircle2 :size="13" />{{ src.name }}
<CheckCircle2 :size="13" />{{ src.name }}<small v-if="state.sourceStatus(src) === 'redirected'">重定向</small>
</span>
</div>
</section>
</div>
<p v-else class="empty">暂无接口源数据后台同步旧 media-types.json 或手动添加后会显示在这里</p>
<p v-else class="empty">暂无接口源数据后台同步旧媒体源配置或手动添加后会显示在这里</p>
</section>
</template>
@@ -23,7 +23,7 @@ export function usePortalState() {
return total + (cat.subcategories || []).filter((item: any) => sourceStatus(item) === "ok").length;
}, 0));
const availability = computed(() => sourceCount.value ? Math.round((healthyCount.value / sourceCount.value) * 100) : 0);
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || "/update-info.json");
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || packages.value[0]?.url || "");
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
const databaseStatus = computed(() => bootstrap.value?.health?.database?.activeProvider || bootstrap.value?.health?.database?.configProvider || "-");
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
@@ -83,7 +83,7 @@ export function sourceStatus(item: any) {
export function statusTone(status: string) {
const value = String(status || "").toLowerCase();
if (["ok", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
if (["ok", "redirected", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
if (["degraded", "pending", "missing"].includes(value)) return "warn";
if (["error", "offline", "failed"].includes(value)) return "bad";
return "neutral";
@@ -2,23 +2,22 @@
color-scheme: light;
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
color: #172033;
background: #f7f9ff;
background: #f5f7f4;
--ink: #172033;
--muted: #63718a;
--soft: #f7f9ff;
--soft: #f5f7f4;
--panel: rgba(255, 255, 255, 0.82);
--panel-strong: #ffffff;
--line: rgba(112, 132, 170, 0.18);
--line-strong: rgba(94, 114, 158, 0.28);
--primary: #3b82f6;
--primary-dark: #2563eb;
--cyan: #06b6d4;
--violet: #8b5cf6;
--pink: #f472b6;
--primary: #1f6f5b;
--primary-dark: #155241;
--accent: #d99227;
--good: #059669;
--warn: #b7791f;
--bad: #dc2626;
--shadow: 0 22px 65px rgba(65, 88, 140, 0.16);
--shadow: 0 22px 65px rgba(31, 48, 40, 0.12);
--ease: cubic-bezier(.2,.8,.2,1);
}
* { box-sizing: border-box; }
@@ -27,9 +26,9 @@ body {
margin: 0;
min-width: 320px;
background:
radial-gradient(circle at 8% 6%, rgba(96, 165, 250, 0.30), transparent 28%),
radial-gradient(circle at 88% 8%, rgba(244, 114, 182, 0.20), transparent 30%),
linear-gradient(180deg, #eef6ff 0%, #f8fbff 42%, #ffffff 100%);
radial-gradient(circle at 8% 6%, rgba(31, 111, 91, 0.10), transparent 30%),
radial-gradient(circle at 90% 8%, rgba(217, 146, 39, 0.10), transparent 30%),
linear-gradient(180deg, #f2f5ef 0%, #f8faf6 42%, #ffffff 100%);
}
body::before {
content: "";
@@ -37,8 +36,8 @@ body::before {
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
linear-gradient(rgba(31, 111, 91, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(31, 111, 91, 0.045) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.55), transparent 70%);
}
@@ -87,8 +86,8 @@ button { cursor: pointer; }
place-items: center;
border-radius: 50%;
color: #fff;
background: linear-gradient(135deg, var(--primary), var(--cyan));
box-shadow: 0 12px 26px rgba(37, 99, 235, 0.26);
background: linear-gradient(135deg, #10231d, var(--primary));
box-shadow: 0 12px 26px rgba(31, 111, 91, 0.22);
}
.brand strong { letter-spacing: 0; }
.nav-links {
@@ -108,18 +107,19 @@ button { cursor: pointer; }
text-decoration: none;
font-size: 14px;
font-weight: 800;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
transition: transform 0.18s var(--ease), background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.nav-links a:hover, .nav-links a.active {
color: var(--primary-dark);
background: rgba(59, 130, 246, 0.12);
background: rgba(31, 111, 91, 0.10);
transform: translateY(-1px);
}
.admin-link {
color: #fff;
background: linear-gradient(135deg, #2563eb, #7c3aed);
box-shadow: 0 12px 28px rgba(59, 130, 246, 0.24);
background: linear-gradient(135deg, #10231d, #1f6f5b);
box-shadow: 0 12px 28px rgba(31, 111, 91, 0.22);
}
.admin-link:hover { box-shadow: 0 16px 36px rgba(59, 130, 246, 0.32); }
.admin-link:hover { transform: translateY(-1px); box-shadow: 0 16px 36px rgba(31, 111, 91, 0.28); }
.hero {
position: relative;
@@ -134,8 +134,8 @@ button { cursor: pointer; }
border-radius: 32px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.62)),
radial-gradient(circle at 88% 18%, rgba(14, 165, 233, 0.26), transparent 34%),
radial-gradient(circle at 18% 82%, rgba(139, 92, 246, 0.18), transparent 30%);
radial-gradient(circle at 88% 18%, rgba(31, 111, 91, 0.14), transparent 34%),
radial-gradient(circle at 18% 82%, rgba(217, 146, 39, 0.13), transparent 30%);
box-shadow: var(--shadow);
padding: clamp(28px, 5vw, 58px);
overflow: hidden;
@@ -148,7 +148,7 @@ button { cursor: pointer; }
width: 360px;
height: 360px;
border-radius: 50%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.20), transparent 68%);
background: radial-gradient(circle, rgba(31, 111, 91, 0.13), transparent 68%);
}
.hero-copy {
position: relative;
@@ -193,7 +193,7 @@ p {
margin-top: 22px;
}
.hero-tags span {
border: 1px solid rgba(59, 130, 246, 0.18);
border: 1px solid rgba(31, 111, 91, 0.16);
border-radius: 999px;
padding: 7px 11px;
color: #355075;
@@ -215,7 +215,7 @@ p {
color: #263856;
font-weight: 900;
box-shadow: 0 10px 26px rgba(65, 88, 140, 0.10);
transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
transition: transform 0.18s var(--ease), box-shadow 0.18s ease, background-color 0.18s ease, border-color 0.18s ease;
}
.button:hover {
transform: translateY(-1px);
@@ -225,8 +225,8 @@ p {
.button.primary {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, #2563eb, #06b6d4);
box-shadow: 0 16px 34px rgba(37, 99, 235, 0.26);
background: linear-gradient(135deg, #10231d, #1f6f5b);
box-shadow: 0 16px 34px rgba(31, 111, 91, 0.24);
}
.release-card, .panel, .metric {
@@ -236,6 +236,13 @@ p {
box-shadow: 0 14px 42px rgba(65, 88, 140, 0.11);
backdrop-filter: blur(16px);
}
.release-card, .panel, .metric, .source-group, .notice-card, .route-list a {
transition: transform 0.22s var(--ease), border-color 0.22s ease, box-shadow 0.22s ease, background-color 0.22s ease;
}
.release-card:hover, .panel:hover, .metric:hover, .source-group:hover, .notice-card:hover, .route-list a:hover {
transform: translateY(-2px);
box-shadow: 0 18px 46px rgba(31, 48, 40, 0.13);
}
.release-card {
position: relative;
z-index: 1;
@@ -396,8 +403,8 @@ input {
outline: none;
}
input:focus {
border-color: rgba(59, 130, 246, 0.65);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.14);
border-color: rgba(31, 111, 91, 0.58);
box-shadow: 0 0 0 4px rgba(31, 111, 91, 0.12);
}
.source-board {
display: grid;
@@ -434,7 +441,7 @@ input:focus {
}
.route-list a:hover {
transform: translateY(-1px);
border-color: rgba(59, 130, 246, 0.36);
border-color: rgba(31, 111, 91, 0.30);
background: rgba(255, 255, 255, 0.92);
}
.route-list span { color: var(--muted); font-size: 13px; font-weight: 700; }
@@ -476,5 +483,5 @@ input:focus {
}
@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; }
}