chore: initialize WanderQ MiniAPP 2.0 repository
This commit is contained in:
2
apps/miniprogram/.env.example
Normal file
2
apps/miniprogram/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
TARO_APP_API_BASE_URL="https://biz.wanderqtrip.com/api"
|
||||
TARO_APP_H5_URL="https://www.example.com"
|
||||
35
apps/miniprogram/README.md
Normal file
35
apps/miniprogram/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 万趣微信小程序
|
||||
|
||||
这是唯一的用户端前台,基于 Taro + React + TypeScript,覆盖探索、搜索、线路详情、收藏、最近浏览、微信登录、手机号授权、我的需求和原生企微“联系我”页。
|
||||
|
||||
小程序已声明企微官方插件 `wx104a1a20c3f81ec2`(版本 `1.4.3`)。`pages/contact/index` 通过公开 API 读取当前联系我 `config_id`,再渲染 `plugin://contactPlugin/cell`;H5 中的“企微联系”入口只负责跳转到该原生页。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
npm run dev:miniapp
|
||||
```
|
||||
|
||||
用微信开发者工具打开 `apps/miniprogram`,并将 `project.config.json` 中的 `appid` 替换为真实小程序 AppID。API 本地内存模式可用:
|
||||
|
||||
```bash
|
||||
MINIAPP_MEMORY_DB=1 npm run dev:api:memory
|
||||
```
|
||||
|
||||
## 生产构建
|
||||
|
||||
在根目录环境中设置:
|
||||
|
||||
```bash
|
||||
TARO_APP_API_BASE_URL="https://biz.wanderqtrip.com/api"
|
||||
```
|
||||
|
||||
然后执行:
|
||||
|
||||
```bash
|
||||
npm run build:miniapp
|
||||
```
|
||||
|
||||
生产 API 需要配置 `WECHAT_APP_ID`、`WECHAT_APP_SECRET`、`DATABASE_URL` 和 `JWT_SECRET`,企微对接还需要按 `docs/backend/README.md` 配置 `WECOM_*` 服务端变量,并将 API 域名与 OSS/CDN 域名加入微信小程序的 `request`、`downloadFile` 合法域名。图片统一使用 API 返回的 OSS WebP 公共 URL,不打包本地图片素材。
|
||||
|
||||
数据库首次部署执行 `npm run db:migrate`,初始化内容执行 `npm run db:seed`。
|
||||
5
apps/miniprogram/babel.config.js
Normal file
5
apps/miniprogram/babel.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
["taro", { framework: "react", ts: true, compiler: "webpack5" }],
|
||||
],
|
||||
};
|
||||
1
apps/miniprogram/config/dev.ts
Normal file
1
apps/miniprogram/config/dev.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
76
apps/miniprogram/config/index.ts
Normal file
76
apps/miniprogram/config/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, type UserConfigExport } from "@tarojs/cli";
|
||||
|
||||
const appRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const DEFAULT_API_BASE_URL = "https://biz.wanderqtrip.com/api";
|
||||
|
||||
class StripWxssWebpackBannersPlugin {
|
||||
apply(compiler: any) {
|
||||
compiler.hooks.thisCompilation.tap("StripWxssWebpackBannersPlugin", (compilation: any) => {
|
||||
compilation.hooks.processAssets.tap(
|
||||
{
|
||||
name: "StripWxssWebpackBannersPlugin",
|
||||
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
|
||||
},
|
||||
(assets: Record<string, any>) => {
|
||||
for (const [filename, asset] of Object.entries(assets)) {
|
||||
if (!filename.endsWith(".wxss")) continue;
|
||||
const source = asset.source().toString();
|
||||
const cleaned = source.replace(/^\/\*![\s\S]*?\*\/\s*/, "");
|
||||
if (cleaned !== source) {
|
||||
compilation.updateAsset(filename, new compiler.webpack.sources.RawSource(cleaned));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const baseConfig: UserConfigExport = {
|
||||
projectName: "wanqu-mini-program",
|
||||
date: "2026-07-22",
|
||||
designWidth: 750,
|
||||
deviceRatio: {
|
||||
640: 2.34 / 2,
|
||||
750: 1,
|
||||
828: 1.81 / 2,
|
||||
},
|
||||
sourceRoot: "src",
|
||||
outputRoot: "dist",
|
||||
framework: "react",
|
||||
compiler: "webpack5",
|
||||
defineConstants: {
|
||||
"process.env.TARO_APP_API_BASE_URL": JSON.stringify(process.env.TARO_APP_API_BASE_URL || DEFAULT_API_BASE_URL),
|
||||
"process.env.TARO_APP_H5_URL": JSON.stringify(process.env.TARO_APP_H5_URL ?? "http://127.0.0.1:5173"),
|
||||
},
|
||||
alias: {
|
||||
// Taro 4.2.1 requires React 18. Keep the mini program's renderer on the
|
||||
// workspace-local React copy while H5/admin can continue using React 19.
|
||||
"react$": path.join(appRoot, "node_modules/react"),
|
||||
},
|
||||
mini: {
|
||||
webpackChain: (chain) => {
|
||||
chain.plugin("strip-wxss-webpack-banners").use(StripWxssWebpackBannersPlugin);
|
||||
},
|
||||
postcss: {
|
||||
pxtransform: { enable: true },
|
||||
url: { enable: true },
|
||||
cssModules: { enable: false },
|
||||
},
|
||||
},
|
||||
h5: {
|
||||
publicPath: "/",
|
||||
staticDirectory: "static",
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig(async (merge, _env) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return merge({}, baseConfig, {
|
||||
mini: { debugReact: true, enableSourceMap: false },
|
||||
});
|
||||
}
|
||||
return merge({}, baseConfig);
|
||||
});
|
||||
1
apps/miniprogram/config/prod.ts
Normal file
1
apps/miniprogram/config/prod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
35
apps/miniprogram/package.json
Normal file
35
apps/miniprogram/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@miniapp/miniprogram",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:weapp": "taro build --type weapp --watch",
|
||||
"build:weapp": "taro build --type weapp",
|
||||
"dev:h5": "taro build --type h5 --watch",
|
||||
"build:h5": "taro build --type h5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tarojs/components": "^4.2.1",
|
||||
"@tarojs/react": "^4.2.1",
|
||||
"@tarojs/runtime": "^4.2.1",
|
||||
"@tarojs/taro": "^4.2.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
"@babel/plugin-transform-runtime": "^7.29.7",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@tarojs/cli": "4.2.1",
|
||||
"@tarojs/plugin-framework-react": "^4.2.1",
|
||||
"@tarojs/plugin-platform-weapp": "^4.2.1",
|
||||
"@tarojs/webpack5-runner": "^4.2.1",
|
||||
"webpack": "5.91.0",
|
||||
"@types/react": "18.2.79",
|
||||
"@types/react-dom": "18.2.25",
|
||||
"babel-preset-taro": "^4.2.1",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
40
apps/miniprogram/project.config.json
Normal file
40
apps/miniprogram/project.config.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"description": "万趣旅行小程序",
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"enhance": true,
|
||||
"postcss": true,
|
||||
"minified": true,
|
||||
"compileHotReLoad": true,
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"uploadWithSourceMap": true,
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": true,
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
}
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.8.8",
|
||||
"appid": "wx6fcf7b3db3deee6c",
|
||||
"projectname": "wanqu-mini-program",
|
||||
"miniprogramRoot": "dist/",
|
||||
"simulatorPluginLibVersion": {},
|
||||
"editorSetting": {}
|
||||
}
|
||||
21
apps/miniprogram/project.private.config.json
Normal file
21
apps/miniprogram/project.private.config.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"libVersion": "3.17.0",
|
||||
"projectname": "wanderQ",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"coverView": false,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"useApiHook": true,
|
||||
"showShadowRootInWxmlPanel": false,
|
||||
"useStaticServer": false,
|
||||
"useLanDebug": false,
|
||||
"showES6CompileOption": false,
|
||||
"compileHotReLoad": true,
|
||||
"checkInvalidKey": true,
|
||||
"ignoreDevUnusedFiles": true,
|
||||
"bigPackageSizeSupport": false
|
||||
}
|
||||
}
|
||||
17
apps/miniprogram/src/app.config.ts
Normal file
17
apps/miniprogram/src/app.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export default defineAppConfig({
|
||||
pages: ["pages/index/index", "pages/contact/index"],
|
||||
plugins: {
|
||||
contactPlugin: {
|
||||
version: "1.4.3",
|
||||
provider: "wx104a1a20c3f81ec2",
|
||||
},
|
||||
},
|
||||
window: {
|
||||
navigationBarTitleText: "万趣,贵州小团旅行专家!",
|
||||
navigationBarBackgroundColor: "#fbfaf5",
|
||||
navigationBarTextStyle: "black",
|
||||
backgroundColor: "#fbfaf5",
|
||||
backgroundColorContent: "#fbfaf5",
|
||||
backgroundTextStyle: "light",
|
||||
},
|
||||
});
|
||||
135
apps/miniprogram/src/app.css
Normal file
135
apps/miniprogram/src/app.css
Normal file
@@ -0,0 +1,135 @@
|
||||
page {
|
||||
background: #f6f4ee;
|
||||
color: #182522;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
page, view, text, image, input, button, scroll-view, swiper, swiper-item {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-shell { min-height: 100vh; padding-bottom: 132px; background: #f6f4ee; }
|
||||
.page { width: 100%; min-height: 100vh; padding: 34px 28px 80px; }
|
||||
.brand-lockup { display: flex; align-items: baseline; gap: 12px; padding: 12px 4px 24px; }
|
||||
.brand-name { font-size: 48px; font-weight: 800; letter-spacing: 4px; }
|
||||
.brand-tagline { color: #7b8580; font-size: 22px; }
|
||||
.hero-swiper, .hero-fallback { height: 390px; overflow: hidden; border-radius: 28px; }
|
||||
.hero-slide { position: relative; width: 100%; height: 100%; overflow: hidden; background: #204440; }
|
||||
.hero-slide > image { width: 100%; height: 100%; }
|
||||
.hero-shade { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(5,20,17,.05), rgba(5,20,17,.74)); }
|
||||
.hero-brand-badge { position: absolute; z-index: 2; top: 18px; left: 18px; display: flex; align-items: center; justify-content: center; width: 156px; height: 38px; padding: 0; pointer-events: none; }
|
||||
.hero-brand-logo { display: block; width: 100%; height: 100%; }
|
||||
.hero-copy { position: absolute; left: 34px; right: 34px; bottom: 32px; display: grid; gap: 10px; color: #fff; }
|
||||
.hero-copy > text:first-child { color: #c6e4dc; font-size: 22px; letter-spacing: 2px; }
|
||||
.hero-copy > text:nth-child(2) { font-size: 38px; font-weight: 800; line-height: 1.2; }
|
||||
.hero-copy button { justify-self: start; margin: 8px 0 0; color: #173b35; background: #e7f2e9; border-radius: 999px; }
|
||||
.hero-fallback { position: relative; display: grid; align-content: end; gap: 16px; padding: 34px; color: #fff; background: #1b4d47; }
|
||||
.hero-fallback > text:nth-of-type(1) { font-size: 42px; font-weight: 800; }
|
||||
.hero-fallback text:last-child { color: #d5e8df; font-size: 24px; line-height: 1.6; }
|
||||
.home-section-head { display: flex; justify-content: space-between; align-items: end; gap: 16px; margin: 38px 4px 18px; }
|
||||
.eyebrow { display: block; color: #728078; font-size: 22px; letter-spacing: 2px; }
|
||||
.section-title { display: block; margin-top: 7px; font-size: 36px; font-weight: 800; line-height: 1.25; }
|
||||
.section-link { color: #19766a; font-size: 22px; }
|
||||
.theme-scroll { display: flex; width: 100%; white-space: nowrap; }
|
||||
.theme-card { position: relative; flex: 0 0 226px; height: 286px; margin-right: 14px; overflow: hidden; border-radius: 22px; background: #d6ded8; }
|
||||
.theme-card image { width: 100%; height: 100%; }
|
||||
.theme-card > view { position: absolute; inset: auto 18px 18px; display: grid; gap: 4px; color: #fff; }
|
||||
.theme-card > view text:first-child { font-size: 28px; font-weight: 700; }
|
||||
.theme-card > view text:last-child { color: #d7e9df; font-size: 18px; }
|
||||
.product-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.product-card { min-width: 0; overflow: hidden; border-radius: 20px; background: #fff; box-shadow: 0 8px 25px rgba(34, 49, 43, .06); }
|
||||
.product-card-image-wrap { position: relative; height: 214px; background: #dbe1dd; }
|
||||
.product-card-image, .product-detail-cover { width: 100%; height: 100%; }
|
||||
.product-image-placeholder { width: 100%; height: 100%; background: #d6ded8; }
|
||||
.favorite-button { position: absolute; top: 14px; right: 14px; width: 48px; height: 48px; display: grid; place-items: center; color: #fff; background: rgba(20, 43, 37, .42); border-radius: 50%; font-size: 34px; line-height: 1; }
|
||||
.favorite-button.is-favorite, .detail-heart.is-favorite { color: #d56f55; }
|
||||
.product-card-copy { display: grid; gap: 8px; padding: 16px; }
|
||||
.product-card-kicker { color: #19766a; font-size: 19px; }
|
||||
.product-card-title { display: block; min-height: 50px; font-size: 25px; font-weight: 750; line-height: 1.25; }
|
||||
.product-card-summary { display: block; height: 44px; overflow: hidden; color: #78817c; font-size: 19px; line-height: 1.25; }
|
||||
.product-card-meta { display: flex; justify-content: space-between; gap: 8px; color: #182522; font-size: 20px; }
|
||||
.product-card-meta text:first-child { color: #c3624c; font-weight: 700; }
|
||||
.demand-banner { display: flex; justify-content: space-between; gap: 16px; margin-top: 24px; padding: 24px; color: #fff; border-radius: 22px; background: #1b4d47; }
|
||||
.demand-banner view { display: grid; gap: 8px; }
|
||||
.demand-banner text:first-child { font-size: 28px; font-weight: 700; }
|
||||
.demand-banner text:nth-child(2) { color: #cfe7df; font-size: 19px; }
|
||||
.demand-banner > text { align-self: end; color: #e0f1e5; font-size: 20px; white-space: nowrap; }
|
||||
.search-head { padding: 28px 4px 16px; }
|
||||
.search-input-wrap { display: flex; align-items: center; gap: 12px; margin-top: 28px; padding: 0 20px; background: #fff; border-radius: 18px; }
|
||||
.search-input-wrap input { flex: 1; height: 76px; padding: 0; color: #182522; font-size: 23px; }
|
||||
.search-input-wrap > text { color: #19766a; font-size: 22px; }
|
||||
.quick-keywords { display: flex; margin-top: 16px; white-space: nowrap; }
|
||||
.quick-keywords text { margin-right: 12px; padding: 9px 16px; color: #557067; background: #e3ede5; border-radius: 999px; font-size: 18px; }
|
||||
.result-count { display: block; margin: 20px 4px; color: #78817c; font-size: 20px; }
|
||||
.empty-card { display: grid; justify-items: center; gap: 13px; padding: 50px 20px; color: #78817c; text-align: center; background: #fff; border-radius: 20px; }
|
||||
.empty-card text:first-child { color: #182522; font-size: 26px; font-weight: 700; }
|
||||
.empty-card button { color: #fff; background: #19766a; border-radius: 999px; }
|
||||
.detail-page, .demand-page { padding-top: 20px; }
|
||||
.detail-back { display: flex; align-items: center; gap: 8px; margin: 0 0 16px 4px; color: #19766a; font-size: 24px; }
|
||||
.detail-cover { display: block; height: 410px; overflow: hidden; border-radius: 24px; }
|
||||
.detail-card { position: relative; margin-top: -26px; padding: 28px 24px 42px; border-radius: 24px 24px 0 0; background: #fff; }
|
||||
.detail-title-row { display: flex; justify-content: space-between; gap: 16px; }
|
||||
.detail-title { display: block; margin-top: 8px; font-size: 38px; font-weight: 800; line-height: 1.22; }
|
||||
.detail-heart { color: #1c5048; font-size: 48px; }
|
||||
.detail-summary { display: block; margin-top: 18px; color: #66736d; font-size: 23px; line-height: 1.55; }
|
||||
.fact-row { display: flex; justify-content: space-between; gap: 16px; margin-top: 22px; padding: 16px 0; border-top: 1px solid #e7ece8; border-bottom: 1px solid #e7ece8; color: #c3624c; font-size: 25px; font-weight: 700; }
|
||||
.muted-text { color: #78817c; font-size: 19px; font-weight: 400; }
|
||||
.key-fact-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 18px; }
|
||||
.key-fact-grid view { display: grid; gap: 4px; padding: 12px; background: #f2f6f2; border-radius: 12px; }
|
||||
.key-fact-grid text:first-child { color: #78817c; font-size: 18px; }
|
||||
.key-fact-grid text:last-child { font-size: 21px; font-weight: 700; }
|
||||
.detail-section { display: grid; gap: 9px; margin-top: 28px; }
|
||||
.detail-section-label { color: #19766a; font-size: 20px; }
|
||||
.detail-section-title { font-size: 28px; font-weight: 750; }
|
||||
.detail-block-text { color: #596962; font-size: 22px; line-height: 1.7; }
|
||||
.detail-inline-image { width: 100%; margin-top: 6px; border-radius: 14px; }
|
||||
.detail-cta, .phone-cta { width: 100%; margin-top: 30px; color: #fff; background: #19766a; border-radius: 999px; font-size: 24px; }
|
||||
.mine-hero { display: flex; align-items: center; gap: 16px; margin: 12px 0 24px; padding: 24px; border-radius: 24px; background: #e4eee5; }
|
||||
.user-avatar.mini { width: 78px; height: 78px; color: #19766a; background: #cce1d2; border-radius: 50%; font-size: 30px; }
|
||||
.user-avatar.mini image { width: 100%; height: 100%; border-radius: 50%; }
|
||||
.mine-name { display: block; margin-top: 7px; font-size: 34px; font-weight: 800; }
|
||||
.mine-note { display: block; margin-top: 5px; color: #6d7d73; font-size: 18px; }
|
||||
.login-prompt { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px; color: #385b51; background: #fff; border-radius: 18px; font-size: 21px; }
|
||||
.login-prompt button { flex: 0 0 auto; color: #fff; background: #19766a; border-radius: 999px; }
|
||||
.mine-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; color: #19766a; font-size: 19px; }
|
||||
.mine-actions button { color: #19766a; background: #e3eee7; border-radius: 999px; }
|
||||
.mine-tabs { display: flex; gap: 24px; margin: 30px 4px 18px; border-bottom: 1px solid #dfe7e1; }
|
||||
.mine-tabs text { padding: 0 0 12px; color: #78817c; font-size: 21px; }
|
||||
.mine-tabs text.active { color: #19766a; border-bottom: 4px solid #19766a; font-weight: 700; }
|
||||
.lead-list { display: grid; gap: 12px; }
|
||||
.mine-lead-card { display: grid; gap: 10px; padding: 20px; background: #fff; border-radius: 18px; }
|
||||
.lead-card-head { display: flex; justify-content: space-between; gap: 12px; font-size: 24px; font-weight: 700; }
|
||||
.lead-status { color: #19766a; font-size: 19px; white-space: nowrap; }
|
||||
.mine-lead-card > text:nth-child(2) { color: #50645a; font-size: 20px; }
|
||||
.demand-page { padding-top: 20px; }
|
||||
.demand-intro { display: block; margin: 15px 0 26px; color: #6d7d73; font-size: 22px; line-height: 1.5; }
|
||||
.selected-route { display: grid; gap: 6px; margin: 18px 0; padding: 16px; color: #365a4f; background: #e2eee6; border-radius: 16px; }
|
||||
.selected-route text:first-child { color: #6d7d73; font-size: 18px; }
|
||||
.selected-route text:last-child { font-size: 23px; font-weight: 700; }
|
||||
.demand-form { display: grid; gap: 10px; padding: 20px; background: #fff; border-radius: 20px; }
|
||||
.demand-form > text { margin-top: 7px; color: #5f7068; font-size: 19px; }
|
||||
.demand-form input { height: 72px; padding: 0 14px; border-bottom: 1px solid #dbe5dd; color: #182522; font-size: 23px; }
|
||||
.privacy-note { display: block; margin-top: 14px; color: #87938c; text-align: center; font-size: 17px; }
|
||||
.bottom-nav { position: fixed; z-index: 10; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(4, 1fr); padding: 12px 18px calc(18px + env(safe-area-inset-bottom)); background: rgba(255, 255, 255, .96); box-shadow: 0 -8px 30px rgba(33, 57, 47, .08); }
|
||||
.bottom-nav view { display: grid; justify-items: center; gap: 4px; color: #89928e; font-size: 17px; }
|
||||
.bottom-nav view text:first-child { font-size: 28px; line-height: 1; }
|
||||
.bottom-nav view.active { color: #19766a; font-weight: 700; }
|
||||
.toast-message, .offline-bar { position: fixed; z-index: 20; top: 20px; left: 28px; right: 28px; padding: 14px 18px; color: #fff; background: #1b4d47; border-radius: 14px; box-shadow: 0 10px 30px rgba(25, 55, 45, .2); font-size: 19px; }
|
||||
.offline-bar { top: 0; border-radius: 0 0 14px 14px; background: #8a5d15; }
|
||||
|
||||
.contact-page { display: grid; align-content: start; gap: 22px; }
|
||||
.contact-hero { display: grid; gap: 12px; padding: 18px 4px 10px; }
|
||||
.contact-title { display: block; font-size: 44px; font-weight: 800; line-height: 1.2; }
|
||||
.contact-plugin-card { display: grid; gap: 12px; padding: 26px 22px; background: #fff; border-radius: 22px; box-shadow: 0 10px 30px rgba(34, 49, 43, .07); }
|
||||
.contact-card-title { display: block; font-size: 28px; font-weight: 800; }
|
||||
.contact-card-note { display: block; color: #718079; font-size: 20px; line-height: 1.5; }
|
||||
.contact-plugin-card cell { margin-top: 8px; }
|
||||
.contact-fallback { background: #e8f1e9; }
|
||||
.contact-back { width: 100%; color: #19766a; background: transparent; border: 1px solid #bcd3c5; border-radius: 999px; font-size: 22px; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.product-grid { grid-template-columns: 1fr; }
|
||||
.hero-copy > text:nth-child(2), .hero-fallback > text:nth-of-type(1) { font-size: 34px; }
|
||||
.section-title { font-size: 31px; }
|
||||
.key-fact-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
8
apps/miniprogram/src/app.tsx
Normal file
8
apps/miniprogram/src/app.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from "react";
|
||||
import "./app.css";
|
||||
|
||||
function App({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
export default App;
|
||||
7
apps/miniprogram/src/pages/contact/index.config.ts
Normal file
7
apps/miniprogram/src/pages/contact/index.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: "联系万趣定制管家",
|
||||
backgroundTextStyle: "light",
|
||||
usingComponents: {
|
||||
cell: "plugin://contactPlugin/cell",
|
||||
},
|
||||
});
|
||||
81
apps/miniprogram/src/pages/contact/index.tsx
Normal file
81
apps/miniprogram/src/pages/contact/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Button, Text, View } from "@tarojs/components";
|
||||
import Taro from "@tarojs/taro";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiUrl } from "../../services/api";
|
||||
|
||||
const SUPPORT_PHONE = "18786174929";
|
||||
|
||||
type ContactWayResponse = {
|
||||
enabled: boolean;
|
||||
configId: string;
|
||||
type: number;
|
||||
scene: number;
|
||||
style: number;
|
||||
remark?: string | null;
|
||||
state?: string | null;
|
||||
pluginId: string;
|
||||
pluginVersion: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CONTACT_WAY: ContactWayResponse = {
|
||||
enabled: true,
|
||||
configId: "5f8a2d2756e2c69776c186ef28fc48ee",
|
||||
type: 1,
|
||||
scene: 1,
|
||||
style: 1,
|
||||
remark: null,
|
||||
state: null,
|
||||
pluginId: "wx104a1a20c3f81ec2",
|
||||
pluginVersion: "1.4.3",
|
||||
};
|
||||
|
||||
export default function ContactPage() {
|
||||
const [contactWay, setContactWay] = useState<ContactWayResponse>(DEFAULT_CONTACT_WAY);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Taro.request<ContactWayResponse>({ url: apiUrl("/api/public/wecom/contact-way") })
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
if (response.statusCode >= 200 && response.statusCode < 300 && response.data?.configId) {
|
||||
setContactWay(response.data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) console.warn("企微联系配置接口暂时不可达,使用内置联系我配置", DEFAULT_CONTACT_WAY.configId);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const callSupport = () => {
|
||||
void Taro.makePhoneCall({ phoneNumber: SUPPORT_PHONE });
|
||||
};
|
||||
|
||||
const showPlugin = Boolean(contactWay.enabled && contactWay.configId);
|
||||
|
||||
return (
|
||||
<View className="page contact-page">
|
||||
<View className="contact-hero">
|
||||
<Text className="eyebrow">联系万趣定制管家</Text>
|
||||
<Text className="contact-title">联系万趣定制管家</Text>
|
||||
</View>
|
||||
{showPlugin ? (
|
||||
<View className="contact-plugin-card">
|
||||
<Text className="contact-card-title">添加企微服务管家</Text>
|
||||
<Text className="contact-card-note">点击按钮后将进入企微添加流程</Text>
|
||||
<cell plugid={contactWay.configId} styleType={contactWay.style} />
|
||||
</View>
|
||||
) : (
|
||||
<View className="contact-plugin-card contact-fallback">
|
||||
<Text className="contact-card-title">企微入口正在准备</Text>
|
||||
<Text className="contact-card-note">当前可以直接电话联系服务管家,我们会继续跟进你的出行需求。</Text>
|
||||
<Button className="phone-cta" onClick={callSupport}>拨打 {SUPPORT_PHONE}</Button>
|
||||
</View>
|
||||
)}
|
||||
<Button className="contact-back" onClick={() => void Taro.navigateBack()}>返回上一页</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
5
apps/miniprogram/src/pages/index/index.config.ts
Normal file
5
apps/miniprogram/src/pages/index/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: "万趣,贵州小团旅行专家!",
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: "light",
|
||||
});
|
||||
9
apps/miniprogram/src/pages/index/index.tsx
Normal file
9
apps/miniprogram/src/pages/index/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { WebView } from "@tarojs/components";
|
||||
|
||||
const H5_URL = String(process.env.TARO_APP_H5_URL ?? "http://127.0.0.1:5173");
|
||||
const H5_CACHE_VERSION = "ui-20260723-tab-seam-v7";
|
||||
const H5_SRC = `${H5_URL}${H5_URL.includes("?") ? "&" : "?"}v=${H5_CACHE_VERSION}`;
|
||||
|
||||
export default function Index() {
|
||||
return <WebView src={H5_SRC} />;
|
||||
}
|
||||
237
apps/miniprogram/src/services/api.ts
Normal file
237
apps/miniprogram/src/services/api.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import Taro from "@tarojs/taro";
|
||||
|
||||
const DEFAULT_API_BASE_URL = "https://biz.wanderqtrip.com/api";
|
||||
const API_BASE = String(process.env.TARO_APP_API_BASE_URL || DEFAULT_API_BASE_URL).replace(/\/+$/, "");
|
||||
const TOKEN_KEY = "wanqu_mini_program_token";
|
||||
const LOCAL_FAVORITES_KEY = "wanqu_mini_program_favorites";
|
||||
const LOCAL_HISTORY_KEY = "wanqu_mini_program_history";
|
||||
|
||||
export function apiUrl(path: string) {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const pathWithoutDuplicatePrefix = API_BASE.endsWith("/api") && (normalizedPath === "/api" || normalizedPath.startsWith("/api/"))
|
||||
? normalizedPath.slice(4) || "/"
|
||||
: normalizedPath;
|
||||
return `${API_BASE}${pathWithoutDuplicatePrefix}`;
|
||||
}
|
||||
|
||||
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;
|
||||
destination?: { id: string; name: string; image?: string | null } | null;
|
||||
destinationId?: string | null;
|
||||
volumeKey?: string | null;
|
||||
durationDays?: number | null;
|
||||
durationNights?: number | null;
|
||||
departureDates?: string[];
|
||||
recommendation?: string | null;
|
||||
images?: Array<{ id?: string; url: string; alt?: string | null; sortOrder?: number }>;
|
||||
keyFacts?: Array<{ label: string; value: string }> | null;
|
||||
detailSections?: Array<{ key: string; label: string; title?: string | null; blocks: Array<{ type: "text" | "image"; text?: string; url?: string; alt?: string | null }> }> | null;
|
||||
};
|
||||
|
||||
export type SiteConfig = {
|
||||
heroSlides: Array<{ id: string; title: string; kicker?: string | null; image: string; targetType?: string | null; targetValue?: string | null }>;
|
||||
destinations: Array<{ id: string; name: string; image?: string | null; isHot: boolean }>;
|
||||
themes: Array<{ id: string; label: string; image: string; targetType?: string | null; targetValue?: string | null }>;
|
||||
ctaBanners: Array<{ id: string; alt: string; image: string; targetType: string; targetValue?: string | null }>;
|
||||
homeModules?: Array<{ id: string; label: string; templateType: string; content: { items: Array<{ id: string; title: string; subtitle?: string; image?: string | null; targetType?: string | null; targetValue?: string | null; productIds?: string[] }> } }>;
|
||||
};
|
||||
|
||||
export type UserProfile = {
|
||||
id: string;
|
||||
nickname?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
phoneMasked: string | null;
|
||||
hasPhone: boolean;
|
||||
status: "active" | "disabled" | "anonymized";
|
||||
source: string;
|
||||
firstSeenAt: string;
|
||||
lastLoginAt: string;
|
||||
createdAt: string;
|
||||
leads?: number;
|
||||
favorites?: number;
|
||||
history?: number;
|
||||
};
|
||||
|
||||
export type UserLead = {
|
||||
id: string;
|
||||
requestType?: "custom" | "booking";
|
||||
destination?: string | null;
|
||||
contactName?: string | null;
|
||||
travelDate?: string | null;
|
||||
peopleCount?: number | null;
|
||||
adultCount?: number | null;
|
||||
childCount?: number | null;
|
||||
plan?: string | null;
|
||||
note?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sourceProduct?: { id: string; title: string; coverImage?: string | null } | null;
|
||||
};
|
||||
|
||||
export type Activity = {
|
||||
favorites: Array<Product & { savedAt: string }>;
|
||||
history: Array<Product & { viewedAt: string; viewCount: number }>;
|
||||
};
|
||||
|
||||
function readStorage<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const value = Taro.getStorageSync(key);
|
||||
return value ? (value as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: unknown) {
|
||||
try {
|
||||
Taro.setStorageSync(key, value);
|
||||
} catch {
|
||||
// Storage can be unavailable in preview/private contexts; network state remains authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return readStorage<string | null>(TOKEN_KEY, null);
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
writeStorage(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
try {
|
||||
Taro.removeStorageSync(TOKEN_KEY);
|
||||
} catch {
|
||||
// Ignore storage cleanup failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocalActivity() {
|
||||
return {
|
||||
favorites: readStorage<string[]>(LOCAL_FAVORITES_KEY, []),
|
||||
history: readStorage<string[]>(LOCAL_HISTORY_KEY, []),
|
||||
};
|
||||
}
|
||||
|
||||
function setLocalIds(key: string, ids: string[]) {
|
||||
writeStorage(key, ids);
|
||||
}
|
||||
|
||||
export function saveLocalFavorite(productId: string, saved: boolean) {
|
||||
const current = getLocalActivity().favorites.filter((id) => id !== productId);
|
||||
setLocalIds(LOCAL_FAVORITES_KEY, saved ? [productId, ...current] : current);
|
||||
}
|
||||
|
||||
export function saveLocalHistory(productId: string) {
|
||||
const current = getLocalActivity().history.filter((id) => id !== productId);
|
||||
setLocalIds(LOCAL_HISTORY_KEY, [productId, ...current].slice(0, 50));
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; data?: unknown } = {}) {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
if (options.data !== undefined) headers["content-type"] = "application/json";
|
||||
const response = await Taro.request<T>({
|
||||
url: apiUrl(path),
|
||||
method: options.method ?? "GET",
|
||||
data: options.data,
|
||||
header: headers,
|
||||
});
|
||||
const body = response.data as T & { message?: string };
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(body?.message ?? `请求失败:${response.statusCode}`);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function loginMiniProgram() {
|
||||
const result = await Taro.login();
|
||||
const response = await request<{ token: string; user: UserProfile }>("/api/public/auth/wechat-login", {
|
||||
method: "POST",
|
||||
data: { code: result.code, source: "mini_program" },
|
||||
});
|
||||
setToken(response.token);
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function fetchContent() {
|
||||
const [site, products] = await Promise.all([
|
||||
request<SiteConfig>("/api/public/site-config"),
|
||||
request<{ items: Product[] }>("/api/public/products"),
|
||||
]);
|
||||
return { site, products: products.items };
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
return request<UserProfile>("/api/public/users/me");
|
||||
}
|
||||
|
||||
export async function updateProfile(data: { nickname?: string | null; avatarUrl?: string | null }) {
|
||||
return request<UserProfile>("/api/public/users/me", { method: "PATCH", data });
|
||||
}
|
||||
|
||||
export async function fetchActivity() {
|
||||
return request<Activity>("/api/public/users/me/activity");
|
||||
}
|
||||
|
||||
export async function syncLocalActivity() {
|
||||
const local = getLocalActivity();
|
||||
const activity = await request<Activity>("/api/public/users/me/activity/sync", {
|
||||
method: "POST",
|
||||
data: { favoriteProductIds: local.favorites, historyProductIds: local.history },
|
||||
});
|
||||
setLocalIds(LOCAL_FAVORITES_KEY, []);
|
||||
setLocalIds(LOCAL_HISTORY_KEY, []);
|
||||
return activity;
|
||||
}
|
||||
|
||||
export async function saveFavorite(productId: string, saved: boolean) {
|
||||
await request(saved ? `/api/public/users/me/favorites/${encodeURIComponent(productId)}` : `/api/public/users/me/favorites/${encodeURIComponent(productId)}`, {
|
||||
method: saved ? "POST" : "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordHistory(productId: string) {
|
||||
await request("/api/public/users/me/history", { method: "POST", data: { productId } });
|
||||
}
|
||||
|
||||
export async function clearHistory() {
|
||||
await request("/api/public/users/me/history", { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function authorizePhone(code: string) {
|
||||
return request<{ user: UserProfile; linkedLeadCount: number }>("/api/public/users/me/phone", { method: "POST", data: { code } });
|
||||
}
|
||||
|
||||
export async function createLead(data: Record<string, unknown>) {
|
||||
return request<{ id: string; status: string }>("/api/public/leads", { method: "POST", data });
|
||||
}
|
||||
|
||||
export async function createBooking(data: Record<string, unknown>) {
|
||||
return request<{ id: string; status: string; requestType: "booking" }>("/api/public/bookings", { method: "POST", data });
|
||||
}
|
||||
|
||||
export async function fetchMyLeads() {
|
||||
return request<UserLead[]>("/api/public/users/me/leads");
|
||||
}
|
||||
|
||||
export async function closeAccount() {
|
||||
return request<{ closed: boolean }>("/api/public/users/me/close", { method: "POST", data: {} });
|
||||
}
|
||||
|
||||
export function resolveAsset(url?: string | null) {
|
||||
if (!url) return "";
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
return apiUrl(url);
|
||||
}
|
||||
22
apps/miniprogram/src/types.d.ts
vendored
Normal file
22
apps/miniprogram/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
declare module "*.css";
|
||||
declare module "*.svg" {
|
||||
const source: string;
|
||||
export default source;
|
||||
}
|
||||
declare module "*.png" {
|
||||
const source: string;
|
||||
export default source;
|
||||
}
|
||||
|
||||
declare namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
cell: {
|
||||
plugid: string;
|
||||
styleType?: number;
|
||||
buttonText?: number;
|
||||
buttonStyle?: number | string;
|
||||
onStartmessage?: (event: unknown) => void;
|
||||
onCompletemessage?: (event: unknown) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
15
apps/miniprogram/tsconfig.json
Normal file
15
apps/miniprogram/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "config"]
|
||||
}
|
||||
Reference in New Issue
Block a user