feat: add WonderQ admin UI

This commit is contained in:
duanshuwen
2026-06-30 12:02:44 +08:00
parent 282b90b622
commit 50e9b32806
160 changed files with 4893 additions and 137 deletions

1353
src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

206
src/api.ts Normal file
View File

@@ -0,0 +1,206 @@
export type Dashboard = {
stats: {
productCount: number;
publishedProductCount: number;
destinationCount: number;
newLeadCount: number;
leadCount: number;
campaignCount: number;
};
recentLeads: Lead[];
};
export type Destination = {
id: string;
name: string;
slug: string;
region?: string | null;
image?: string | null;
isHot: boolean;
isActive: boolean;
sortOrder: number;
aliases?: { id: string; alias: string }[];
_count?: { products: number };
};
export type Product = {
id: string;
sourceId?: number | null;
title: string;
subtitle?: string | null;
priceAmount?: number | null;
priceUnit: string;
tags: string[];
coverImage?: string | null;
summary?: string | null;
images?: Array<{ id?: string; url: string; alt?: string | null; sortOrder: number }>;
detailSections?: ProductDetailSection[] | null;
status: "draft" | "published" | "archived";
sortWeight: number;
updatedAt: string;
destination?: Destination | null;
destinationId?: string | null;
};
export type ProductDetailBlock =
| { type: "text"; text: string }
| { type: "image"; url: string; alt?: string | null };
export type ProductDetailSection = {
key: string;
label: string;
title?: string | null;
blocks: ProductDetailBlock[];
};
export type Lead = {
id: string;
destination?: string | null;
phone: string;
note?: string | null;
sourcePage?: string | null;
status: "new" | "assigned" | "contacted" | "planning" | "won" | "invalid";
createdAt: string;
sourceProduct?: { id: string; title: string } | null;
assignedUser?: { id: string; name: string } | null;
};
export type SiteConfig = {
heroSlides: Array<{ id: string; title: string; kicker?: string | null; image: string; targetType?: string | null; targetValue?: string | null; isActive: boolean }>;
destinations: Destination[];
themes: Array<{ id: string; label: string; image: string; targetType?: string | null; targetValue?: string | null; isActive: boolean }>;
ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null; isActive: boolean }>;
};
export type SiteModule = "heroSlides" | "destinations" | "themes" | "ctaBanners";
export type SiteItemPatch = {
title?: string;
kicker?: string;
name?: string;
label?: string;
alt?: string;
image?: string | null;
targetType?: string | null;
targetValue?: string | null;
isActive?: boolean;
};
export type ProductInput = {
title: string;
subtitle?: string;
destinationId?: string | null;
priceAmount?: number | null;
priceUnit?: string;
tags: string[];
coverImage?: string | null;
summary?: string | null;
images?: Array<{ url: string; alt?: string | null; sortOrder: number }>;
detailSections?: ProductDetailSection[];
status: "draft" | "published" | "archived";
sortWeight: number;
};
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const TOKEN_KEY = "miniapp_admin_token";
export function getToken() {
return window.localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
window.localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
window.localStorage.removeItem(TOKEN_KEY);
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.message ?? `请求失败:${response.status}`);
}
return response.json() as Promise<T>;
}
export async function login(email: string, password: string) {
return request<{ token: string; user: { name: string; email: string; role: string } }>("/api/admin/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
export async function getDashboard() {
return request<Dashboard>("/api/admin/dashboard");
}
export async function getProducts(keyword = "") {
const search = keyword ? `?keyword=${encodeURIComponent(keyword)}` : "";
return request<{ items: Product[] }>(`/api/admin/products${search}`);
}
export async function createProduct(input: ProductInput) {
return request<Product>("/api/admin/products", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateProduct(id: string, input: Partial<ProductInput>) {
return request<Product>(`/api/admin/products/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export async function getDestinations() {
return request<{ items: Destination[] }>("/api/admin/destinations");
}
export async function getSiteConfig() {
return request<SiteConfig>("/api/admin/site-config");
}
export async function updateSiteConfigItem(module: SiteModule, id: string, input: SiteItemPatch) {
return request(`/api/admin/site-config/${module}/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export async function getLeads() {
return request<{ items: Lead[] }>("/api/admin/leads");
}
export async function updateLeadStatus(id: string, status: Lead["status"]) {
return request<Lead>(`/api/admin/leads/${id}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
}
export async function publishSite() {
return request<{ id: string; title: string; publishedAt: string }>("/api/admin/publish", {
method: "POST",
body: JSON.stringify({}),
});
}
export async function resetGuizhouContent() {
return request<{ heroSlides: number; destinations: number; themes: number; ctaBanners: number; products: number }>("/api/admin/reset-guizhou-content", {
method: "POST",
body: JSON.stringify({}),
});
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

1448
src/styles.css Normal file

File diff suppressed because it is too large Load Diff

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />