chore: initialize WanderQ MiniAPP 2.0 repository

This commit is contained in:
inman
2026-07-23 15:48:34 +08:00
commit b4c0a1c516
93 changed files with 53525 additions and 0 deletions

12
apps/admin/index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

494
apps/admin/src/api.ts Normal file
View 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
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>,
);

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
View File

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

20
apps/admin/tsconfig.json Normal file
View 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
View 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,
},
},
},
};
});