feat: add WonderQ admin UI
This commit is contained in:
1353
src/App.tsx
Normal file
1353
src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
206
src/api.ts
Normal file
206
src/api.ts
Normal 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
10
src/main.tsx
Normal 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
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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user