chore: initialize WanderQ MiniAPP 2.0 repository
This commit is contained in:
12
apps/admin/index.html
Normal file
12
apps/admin/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>万趣小程序管理端</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
apps/admin/package.json
Normal file
23
apps/admin/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@miniapp/admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5601",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 5602"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"lucide-react": "^1.21.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"vite": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
3622
apps/admin/src/App.tsx
Normal file
3622
apps/admin/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
494
apps/admin/src/api.ts
Normal file
494
apps/admin/src/api.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
export type Dashboard = {
|
||||
stats: {
|
||||
productCount: number;
|
||||
publishedProductCount: number;
|
||||
destinationCount: number;
|
||||
newLeadCount: number;
|
||||
leadCount: number;
|
||||
bookingCount: number;
|
||||
newBookingCount: number;
|
||||
campaignCount: number;
|
||||
userCount: number;
|
||||
activeUserCount: 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;
|
||||
pricingTiers?: ProductPricingTier[] | null;
|
||||
tags: string[];
|
||||
coverImage?: string | null;
|
||||
summary?: string | null;
|
||||
durationDays?: number | null;
|
||||
durationNights?: number | null;
|
||||
departureDates?: string[];
|
||||
remainingSpots?: number | null;
|
||||
recommendation?: string | null;
|
||||
images?: Array<{ id?: string; url: string; alt?: string | null; sortOrder: number }>;
|
||||
keyFacts?: ProductKeyFact[] | null;
|
||||
contentBlocks?: ProductContentBlock[] | null;
|
||||
detailSections?: ProductDetailSection[] | null;
|
||||
status: "draft" | "published" | "archived";
|
||||
sortWeight: number;
|
||||
updatedAt: string;
|
||||
destination?: Destination | null;
|
||||
destinationId?: string | null;
|
||||
};
|
||||
|
||||
export type ProductKeyFact = { label: string; value: string };
|
||||
|
||||
export type ProductPricingTier = {
|
||||
groupSize: number;
|
||||
adultPrice: number;
|
||||
child6PlusPrice: number;
|
||||
childUnder6Price: number;
|
||||
};
|
||||
|
||||
export type ProductContentBlock =
|
||||
| { type: "title"; text: string }
|
||||
| { type: "image"; url: string; alt?: 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;
|
||||
requestType: "custom" | "booking";
|
||||
destination?: string | null;
|
||||
phone: string;
|
||||
contactName?: string | null;
|
||||
wechat?: string | null;
|
||||
travelDate?: string | null;
|
||||
peopleCount?: number | null;
|
||||
adultCount?: number | null;
|
||||
childCount?: number | null;
|
||||
roomType?: string | null;
|
||||
plan?: string | null;
|
||||
addOns?: string[];
|
||||
budgetMin?: number | null;
|
||||
budgetMax?: number | null;
|
||||
note?: string | null;
|
||||
sourcePage?: string | null;
|
||||
sourceTheme?: string | null;
|
||||
adminNote?: string | null;
|
||||
processed: boolean;
|
||||
status: "new" | "assigned" | "contacted" | "planning" | "awaiting_confirmation" | "won" | "closed" | "invalid";
|
||||
createdAt: string;
|
||||
sourceProduct?: { id: string; title: string } | null;
|
||||
assignedUser?: { id: string; name: string } | null;
|
||||
user?: { id: string; nickname?: string | null; phone?: string | null; phoneMasked?: string } | null;
|
||||
followups?: LeadFollowup[];
|
||||
};
|
||||
|
||||
export type LeadFollowup = {
|
||||
id: string;
|
||||
leadId: string;
|
||||
content: string;
|
||||
nextAt?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type MiniProgramUser = {
|
||||
id: string;
|
||||
nickname?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
phone?: string | null;
|
||||
phoneMasked: string;
|
||||
hasPhone: boolean;
|
||||
status: "active" | "disabled" | "anonymized";
|
||||
source: string;
|
||||
note?: string | null;
|
||||
firstSeenAt: string;
|
||||
lastLoginAt: string;
|
||||
createdAt: string;
|
||||
counts: { leads: number; favorites: number; histories: number };
|
||||
};
|
||||
|
||||
export type MiniProgramUserDetail = MiniProgramUser & {
|
||||
leads: Lead[];
|
||||
favorites: Array<{ id: string; title: string; coverImage?: string | null; destination?: { name: string } | null; savedAt: string }>;
|
||||
histories: Array<{ id: string; title: string; coverImage?: string | null; destination?: { name: string } | null; viewedAt: string; viewCount: number }>;
|
||||
};
|
||||
|
||||
export type AdminLeadDetail = Lead & {
|
||||
user?: { id: string; nickname?: string | null; phone?: string | null; phoneMasked?: string } | null;
|
||||
followups: LeadFollowup[];
|
||||
};
|
||||
|
||||
export type LeadListFilters = {
|
||||
requestType?: Lead["requestType"];
|
||||
status?: Lead["status"];
|
||||
processed?: boolean;
|
||||
keyword?: string;
|
||||
};
|
||||
|
||||
export type HomeModuleTemplateType = "explore" | "themes" | "deals" | "hotels" | "vehicles";
|
||||
|
||||
export type HomeModuleContentItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
image?: string | null;
|
||||
targetType?: string | null;
|
||||
targetValue?: string | null;
|
||||
productIds?: string[];
|
||||
chips?: string[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type HomeModuleContent = {
|
||||
items: HomeModuleContentItem[];
|
||||
};
|
||||
|
||||
export type HomeModule = {
|
||||
id: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
templateType: HomeModuleTemplateType;
|
||||
content: HomeModuleContent;
|
||||
isSystem: boolean;
|
||||
isDeleted: boolean;
|
||||
publishedConfig?: {
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
templateType: HomeModuleTemplateType;
|
||||
content: HomeModuleContent;
|
||||
isDeleted: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type HomeModuleTemplate = {
|
||||
id: HomeModuleTemplateType;
|
||||
name: string;
|
||||
defaultLabel: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type SearchPageKeyword = {
|
||||
id: string;
|
||||
label: string;
|
||||
image: string | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type SearchPageGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
items: SearchPageKeyword[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type SearchPageModuleCopy = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
export type SearchPageConfig = {
|
||||
placeholder: string;
|
||||
modules: {
|
||||
seasonalInspiration: SearchPageModuleCopy;
|
||||
preferenceDiscovery: SearchPageModuleCopy;
|
||||
};
|
||||
popularKeywords: SearchPageKeyword[];
|
||||
groups: SearchPageGroup[];
|
||||
};
|
||||
|
||||
export type SiteConfig = {
|
||||
homeModules: HomeModule[];
|
||||
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 }>;
|
||||
destinationRecommendations: { productIds: string[] };
|
||||
searchPage: SearchPageConfig;
|
||||
};
|
||||
|
||||
export type SiteModule = "homeModules" | "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;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
templateType?: HomeModuleTemplateType;
|
||||
content?: HomeModuleContent;
|
||||
};
|
||||
|
||||
export type ProductInput = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
destinationId?: string | null;
|
||||
priceAmount?: number | null;
|
||||
priceUnit?: string;
|
||||
pricingTiers?: ProductPricingTier[];
|
||||
tags: string[];
|
||||
coverImage?: string | null;
|
||||
summary?: string | null;
|
||||
durationDays?: number | null;
|
||||
durationNights?: number | null;
|
||||
departureDates?: string[];
|
||||
remainingSpots?: number | null;
|
||||
recommendation?: string | null;
|
||||
images?: Array<{ url: string; alt?: string | null; sortOrder: number }>;
|
||||
keyFacts?: ProductKeyFact[];
|
||||
contentBlocks?: ProductContentBlock[];
|
||||
detailSections?: ProductDetailSection[];
|
||||
status: "draft" | "published" | "archived";
|
||||
sortWeight: number;
|
||||
};
|
||||
|
||||
export type MediaAsset = {
|
||||
id: string;
|
||||
url: string;
|
||||
name?: string | null;
|
||||
mimeType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
group?: string | null;
|
||||
};
|
||||
|
||||
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 uploadMedia(file: File) {
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/admin/media-assets/upload`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.message ?? `上传失败:${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<MediaAsset>;
|
||||
}
|
||||
|
||||
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 updateDestinationRecommendations(productIds: string[]) {
|
||||
return request<{ productIds: string[] }>("/api/admin/destination-recommendations", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ productIds }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSearchPageConfig(config: SearchPageConfig) {
|
||||
return request<SearchPageConfig>("/api/admin/search-page-config", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getHomeModuleTemplates() {
|
||||
return request<HomeModuleTemplate[]>("/api/admin/home-module-templates");
|
||||
}
|
||||
|
||||
export async function createHomeModule(input: { templateType: HomeModuleTemplateType; label?: string; sortOrder?: number }) {
|
||||
return request<HomeModule>("/api/admin/home-modules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteHomeModule(id: string) {
|
||||
return request<{ id: string; deleted: boolean }>(`/api/admin/home-modules/${id}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
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(filters: LeadListFilters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.requestType) params.set("requestType", filters.requestType);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.processed !== undefined) params.set("processed", String(filters.processed));
|
||||
if (filters.keyword?.trim()) params.set("keyword", filters.keyword.trim());
|
||||
const query = params.toString() ? `?${params.toString()}` : "";
|
||||
return request<{ items: Lead[]; total: number }>(`/api/admin/leads${query}`);
|
||||
}
|
||||
|
||||
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 updateLeadProcessed(id: string, processed = true) {
|
||||
return request<Lead>(`/api/admin/leads/${id}/processed`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ processed }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLeadAdminNote(id: string, adminNote: string | null) {
|
||||
return request<Lead>(`/api/admin/leads/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ adminNote }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUsers(keyword = "", status = "") {
|
||||
const params = new URLSearchParams();
|
||||
if (keyword.trim()) params.set("keyword", keyword.trim());
|
||||
if (status) params.set("status", status);
|
||||
const query = params.toString() ? `?${params.toString()}` : "";
|
||||
return request<{ items: MiniProgramUser[]; total: number; take: number; skip: number }>(`/api/admin/users${query}`);
|
||||
}
|
||||
|
||||
export async function getUser(id: string) {
|
||||
return request<MiniProgramUserDetail>(`/api/admin/users/${id}`);
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, input: { status?: "active" | "disabled"; note?: string | null }) {
|
||||
return request<{ id: string; status: MiniProgramUser["status"]; note?: string | null; phoneMasked: string }>(`/api/admin/users/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAdminLead(id: string) {
|
||||
return request<AdminLeadDetail>(`/api/admin/leads/${id}`);
|
||||
}
|
||||
|
||||
export async function revealUserPhone(id: string) {
|
||||
return request<{ phone: string | null }>(`/api/admin/users/${id}/phone`);
|
||||
}
|
||||
|
||||
export async function revealLeadPhone(id: string) {
|
||||
return request<{ phone: string | null }>(`/api/admin/leads/${id}/phone`);
|
||||
}
|
||||
|
||||
export async function addLeadFollowup(id: string, input: { content: string; nextAt?: string | null }) {
|
||||
return request<LeadFollowup>(`/api/admin/leads/${id}/followups`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetGuizhouContent() {
|
||||
return request<{ homeModules: number; heroSlides: number; destinations: number; themes: number; ctaBanners: number; products: number }>("/api/admin/reset-guizhou-content", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
10
apps/admin/src/main.tsx
Normal file
10
apps/admin/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>,
|
||||
);
|
||||
4511
apps/admin/src/styles.css
Normal file
4511
apps/admin/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/admin/src/vite-env.d.ts
vendored
Normal file
1
apps/admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
apps/admin/tsconfig.json
Normal file
20
apps/admin/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
26
apps/admin/vite.config.ts
Normal file
26
apps/admin/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig(() => {
|
||||
const apiTarget = process.env.VITE_DEV_API_PROXY_TARGET ?? "http://localhost:4000";
|
||||
return {
|
||||
plugins: [react()],
|
||||
publicDir: "../../public",
|
||||
resolve: {
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
server: {
|
||||
port: 5601,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/uploads": {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user