lxf преди 1 седмица
родител
ревизия
655b057b7b
променени са 27 файла, в които са добавени 852 реда и са изтрити 215 реда
  1. 3 0
      .env
  2. 3 0
      .env.dev
  3. 3 0
      .env.pro
  4. 23 214
      .gitignore
  5. 90 1
      README.md
  6. 26 0
      env.config.js
  7. 20 0
      index.html
  8. 9 0
      jsconfig.json
  9. 62 0
      package.json
  10. 0 0
      public/favicon.ico
  11. 14 0
      src/App.vue
  12. 28 0
      src/api/config.js
  13. 16 0
      src/api/modules/system.js
  14. 126 0
      src/common/ol_common.js
  15. 6 0
      src/config.js
  16. 18 0
      src/main.js
  17. 106 0
      src/plugins/axios.js
  18. 13 0
      src/plugins/element.js
  19. 28 0
      src/plugins/permission.js
  20. 47 0
      src/router/helper.js
  21. 10 0
      src/router/index.js
  22. 24 0
      src/router/routes.js
  23. 34 0
      src/styles/common.scss
  24. 12 0
      src/styles/variables.scss.js
  25. 16 0
      src/utils/auth.js
  26. 37 0
      src/views/home/index.vue
  27. 78 0
      vite.config.js

+ 3 - 0
.env

@@ -0,0 +1,3 @@
+SERVER = "http://127.0.0.1:81/"
+PYSERVER = "http://127.0.0.1:5000/"
+NEW_SERVER = "http://127.0.0.1:81/"

+ 3 - 0
.env.dev

@@ -0,0 +1,3 @@
+SERVER = "https://feiniaotech-dev.sysuimars.cn/"
+PYSERVER = "https://birds-api-1-2.sysuimars.cn/"
+NEW_SERVER = "https://woot.feiniaotech.com/"

+ 3 - 0
.env.pro

@@ -0,0 +1,3 @@
+SERVER = "https://birdseye-api.feiniaotech.sysuimars.cn/"
+PYSERVER = "https://birds-api-1-2.sysuimars.cn/"
+NEW_SERVER = "https://woot.feiniaotech.com/"

+ 23 - 214
.gitignore

@@ -1,223 +1,32 @@
-# ---> VisualStudio
-## Ignore Visual Studio temporary files, build results, and
-## files generated by popular Visual Studio add-ons.
-
-# User-specific files
+# Dependencies
+node_modules/
+dist/
+*.local
+package-lock.json
+
+# Logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor
+.idea/
+.vscode/
 *.suo
 *.user
 *.userosscache
 *.sln.docstates
-
-# User-specific files (MonoDevelop/Xamarin Studio)
-*.userprefs
-
-# Build results
-[Dd]ebug/
-[Dd]ebugPublic/
-[Rr]elease/
-[Rr]eleases/
-x64/
-x86/
-build/
-bld/
-[Bb]in/
-[Oo]bj/
-
-# Visual Studio 2015 cache/options directory
 .vs/
-# Uncomment if you have tasks that create the project's static files in wwwroot
-#wwwroot/
-
-# MSTest test Results
-[Tt]est[Rr]esult*/
-[Bb]uild[Ll]og.*
 
-# NUNIT
-*.VisualState.xml
-TestResult.xml
+# OS
+.DS_Store
+Thumbs.db
 
-# Build Results of an ATL Project
-[Dd]ebugPS/
-[Rr]eleasePS/
-dlldata.c
-
-# DNX
-project.lock.json
-artifacts/
-
-*_i.c
-*_p.c
-*_i.h
-*.ilk
-*.meta
-*.obj
-*.pch
-*.pdb
-*.pgc
-*.pgd
-*.rsp
-*.sbr
-*.tlb
-*.tli
-*.tlh
-*.tmp
-*.tmp_proj
+# Build / cache
 *.log
-*.vspscc
-*.vssscc
-.builds
-*.pidb
-*.svclog
-*.scc
-
-# Chutzpah Test files
-_Chutzpah*
-
-# Visual C++ cache files
-ipch/
-*.aps
-*.ncb
-*.opensdf
-*.sdf
-*.cachefile
-
-# Visual Studio profiler
-*.psess
-*.vsp
-*.vspx
-*.sap
-
-# TFS 2012 Local Workspace
-$tf/
-
-# Guidance Automation Toolkit
-*.gpState
-
-# ReSharper is a .NET coding add-in
-_ReSharper*/
-*.[Rr]e[Ss]harper
-*.DotSettings.user
-
-# JustCode is a .NET coding add-in
-.JustCode
-
-# TeamCity is a build add-in
-_TeamCity*
-
-# DotCover is a Code Coverage Tool
-*.dotCover
-
-# NCrunch
-_NCrunch_*
-.*crunch*.local.xml
-nCrunchTemp_*
-
-# MightyMoose
-*.mm.*
-AutoTest.Net/
-
-# Web workbench (sass)
-.sass-cache/
-
-# Installshield output folder
-[Ee]xpress/
-
-# DocProject is a documentation generator add-in
-DocProject/buildhelp/
-DocProject/Help/*.HxT
-DocProject/Help/*.HxC
-DocProject/Help/*.hhc
-DocProject/Help/*.hhk
-DocProject/Help/*.hhp
-DocProject/Help/Html2
-DocProject/Help/html
-
-# Click-Once directory
-publish/
-
-# Publish Web Output
-*.[Pp]ublish.xml
-*.azurePubxml
-# TODO: Comment the next line if you want to checkin your web deploy settings 
-# but database connection strings (with potential passwords) will be unencrypted
-*.pubxml
-*.publishproj
-
-# NuGet Packages
-*.nupkg
-# The packages folder can be ignored because of Package Restore
-**/packages/*
-# except build/, which is used as an MSBuild target.
-!**/packages/build/
-# Uncomment if necessary however generally it will be regenerated when needed
-#!**/packages/repositories.config
-
-# Windows Azure Build Output
-csx/
-*.build.csdef
-
-# Windows Store app package directory
-AppPackages/
-
-# Visual Studio cache files
-# files ending in .cache can be ignored
-*.[Cc]ache
-# but keep track of directories ending in .cache
-!*.[Cc]ache/
-
-# Others
-ClientBin/
-[Ss]tyle[Cc]op.*
-~$*
-*~
-*.dbmdl
-*.dbproj.schemaview
-*.pfx
-*.publishsettings
-node_modules/
-orleans.codegen.cs
-
-# RIA/Silverlight projects
-Generated_Code/
-
-# Backup & report files from converting an old project file
-# to a newer Visual Studio version. Backup files are not needed,
-# because we have git ;-)
-_UpgradeReport_Files/
-Backup*/
-UpgradeLog*.XML
-UpgradeLog*.htm
-
-# SQL Server files
-*.mdf
-*.ldf
-
-# Business Intelligence projects
-*.rdl.data
-*.bim.layout
-*.bim_*.settings
-
-# Microsoft Fakes
-FakesAssemblies/
-
-# Node.js Tools for Visual Studio
-.ntvs_analysis.dat
-
-# Visual Studio 6 build log
-*.plg
-
-# Visual Studio 6 workspace options file
-*.opt
-
-# Visual Studio LightSwitch build output
-**/*.HTMLClient/GeneratedArtifacts
-**/*.DesktopClient/GeneratedArtifacts
-**/*.DesktopClient/ModelManifest.xml
-**/*.Server/GeneratedArtifacts
-**/*.Server/ModelManifest.xml
-_Pvt_Extensions
-
-# ---> VisualStudioCode
-.settings
-
+.cache/
+coverage/
 
+# Env secrets (keep .env.dev / .env.pro templates if needed)
+.env.*.local

+ 90 - 1
README.md

@@ -1,3 +1,92 @@
 # feiniao-be-data
 
-后台数据平台
+飞鸟后台数据平台前端脚手架(基于 feiniao-farm-h5 精简抽取)。
+
+## 技术栈
+
+- Vue 3 + Vue Router 4 + Axios
+- Element Plus
+- OpenLayers(`src/common/ol_common.js`)
+
+## 快速开始
+
+```bash
+npm install
+npm run dev
+```
+
+| 命令 | 说明 |
+|------|------|
+| `npm run dev` | 开发环境(Vite) |
+| `npm run local` | 本地环境 |
+| `npm run pro` | 生产接口联调 |
+| `npm run build` | 生产构建 |
+| `npm run preview` | 预览构建产物 |
+
+## 目录结构
+
+```
+src/
+├── api/
+│   ├── config.js          # 接口域名 / CDN / 忽略提示关键字
+│   └── modules/           # 接口模块(自动挂载为 VE_API.xxx)
+├── common/
+│   └── ol_common.js       # 地图公共方法(WMTS / WKT / fit)
+├── plugins/
+│   ├── axios.js           # 请求封装(token 走 localStorage)
+│   ├── element.js
+│   └── permission.js      # 路由守卫(title / token)
+├── router/
+│   ├── helper.js          # createPage(默认 keepAlive: true)
+│   ├── routes.js          # 路由表
+│   └── index.js
+├── utils/auth.js          # token 读写
+├── views/home/            # 脚手架占位页(可替换)
+├── styles/
+├── App.vue                # 全局 keep-alive
+└── main.js
+```
+
+## 新增页面(推荐写法)
+
+1. 新建 `src/views/xxx/index.vue`(纯 `<script setup>`)
+2. 在 `src/router/routes.js` 注册:
+
+```js
+import { createPage } from "./helper";
+
+export default [
+  createPage({
+    path: "/xxx",
+    name: "Xxx",
+    component: () => import("@/views/xxx/index.vue"),
+    title: "页面标题",
+    // keepAlive: false, // 默认 true,不需要缓存时关闭
+  }),
+];
+```
+
+## 新增接口
+
+在 `src/api/modules/` 新建文件,例如 `farm.js`:
+
+```js
+const config = require("../config");
+
+module.exports = {
+  list: {
+    url: config.base_dev_url + "v2/farm/list",
+    type: "get",
+  },
+};
+```
+
+页面调用:`this.VE_API.farm.list(params)` 或 `proxy.VE_API.farm.list(params)`。
+
+## 环境变量
+
+环境配置在 `env.config.js`,由 Vite `define` 注入为 `VE_ENV`:
+
+- `SERVER` → `base_dev_url`
+- `PYSERVER` → `base_py_url`
+- `NEW_SERVER` → `base_new_url`

+ 26 - 0
env.config.js

@@ -0,0 +1,26 @@
+// 环境变量配置(供 vite define → VE_ENV)
+const envConfig = {
+  development: {
+    NODE_ENV: "development",
+    SERVER: "https://feiniaotech-dev.sysuimars.cn/",
+    PYSERVER: "https://birds-api-1-2.sysuimars.cn/",
+    NEW_SERVER: "https://woot.feiniaotech.com/",
+  },
+  local: {
+    NODE_ENV: "local",
+    SERVER: "http://127.0.0.1:81/",
+    PYSERVER: "http://127.0.0.1:5000/",
+    NEW_SERVER: "http://127.0.0.1:81/",
+  },
+  production: {
+    NODE_ENV: "production",
+    SERVER: "https://birdseye-api.feiniaotech.sysuimars.cn/",
+    PYSERVER: "https://birds-api-1-2.sysuimars.cn/",
+    NEW_SERVER: "https://woot.feiniaotech.com/",
+  },
+};
+
+// vite --mode pro 时复用 production
+envConfig.pro = envConfig.production;
+
+export default envConfig;

+ 20 - 0
index.html

@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
+    />
+    <link rel="icon" href="/favicon.ico" />
+    <title>飞鸟数据平台</title>
+  </head>
+  <body>
+    <noscript>
+      <strong>请启用 JavaScript 以运行本应用。</strong>
+    </noscript>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 9 - 0
jsconfig.json

@@ -0,0 +1,9 @@
+{
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "exclude": ["node_modules", "dist"]
+}

+ 62 - 0
package.json

@@ -0,0 +1,62 @@
+{
+  "name": "feiniao-be-data",
+  "version": "0.1.0",
+  "private": true,
+  "description": "飞鸟后台数据平台 - Vue3 脚手架",
+  "scripts": {
+    "dev": "vite",
+    "local": "vite --mode local",
+    "pro": "vite --mode production",
+    "build": "vite build --mode production",
+    "preview": "vite preview",
+    "lint": "eslint . --ext .vue,.js --fix"
+  },
+  "dependencies": {
+    "@element-plus/icons-vue": "^2.0.6",
+    "axios": "^0.27.2",
+    "core-js": "^3.8.3",
+    "dayjs": "^1.11.10",
+    "element-plus": "^2.2.8",
+    "jsts": "^2.12.1",
+    "nprogress": "^0.2.0",
+    "ol": "^6.15.1",
+    "qs": "^6.11.0",
+    "vue": "^3.2.13",
+    "vue-router": "^4.1.0"
+  },
+  "devDependencies": {
+    "@babel/eslint-parser": "^7.12.16",
+    "@vitejs/plugin-vue": "^4.5.0",
+    "eslint": "^7.32.0",
+    "eslint-plugin-vue": "^8.0.3",
+    "sass": "^1.53.0",
+    "vite": "^4.5.0"
+  },
+  "eslintConfig": {
+    "root": true,
+    "env": {
+      "node": true
+    },
+    "globals": {
+      "VE_ENV": "readonly",
+      "VE_API": "readonly"
+    },
+    "extends": [
+      "plugin:vue/vue3-essential",
+      "eslint:recommended"
+    ],
+    "parserOptions": {
+      "parser": "@babel/eslint-parser",
+      "requireConfigFile": false
+    },
+    "rules": {
+      "vue/multi-word-component-names": "off"
+    }
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not dead",
+    "not ie 11"
+  ]
+}

+ 0 - 0
public/favicon.ico


+ 14 - 0
src/App.vue

@@ -0,0 +1,14 @@
+<template>
+  <router-view v-slot="{ Component }">
+    <keep-alive v-if="route.meta && route.meta.keepAlive !== false">
+      <component :is="Component" />
+    </keep-alive>
+    <component v-else :is="Component" />
+  </router-view>
+</template>
+
+<script setup>
+import { useRoute } from "vue-router";
+
+const route = useRoute();
+</script>

+ 28 - 0
src/api/config.js

@@ -0,0 +1,28 @@
+const newServer = VE_ENV.SERVER;
+const pyServer = VE_ENV.PYSERVER;
+const newPathServer = VE_ENV.NEW_SERVER;
+const oldServer = "https://birdseye-api.sysuimars.com/";
+const fosterServer = "https://foster-api.sysuimars.com/";
+
+export default {
+  base_url: oldServer + "site/",
+  base_dev_site_url: newServer + "site/",
+  base_foster_url: fosterServer + "app/",
+  base_mini_url: oldServer + "mini/",
+  base_dev_url: newServer + "mini/",
+  base_new_url: newPathServer + "api/",
+  base_py_url: pyServer,
+  image_url: oldServer + "images/",
+  mini_key: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9",
+  base_img_url: "https://img.sysuimars.com/",
+  base_img_url2: "https://birdseye-img-ali-cdn.sysuimars.com/",
+  base_img_url3: "https://birdseye-img.sysuimars.com/",
+  base_img_url4: "https://xyz2.sysuimars.cn/",
+  base_video_url: "https://minio.sysuimars.com/sysuimars/",
+  resize: "?x-oss-process=image/resize,w_1000",
+  resize_300: "?x-oss-process=image/resize,w_300,p_30/format,webp/quality,q_20",
+
+  getOptBody: (opt) => {
+    return JSON.parse(opt.body);
+  },
+};

+ 16 - 0
src/api/modules/system.js

@@ -0,0 +1,16 @@
+/**
+ * API 模块示例
+ * 新增业务接口:在 modules 下新建 xxx.js,自动挂到 VE_API.xxx
+ *
+ * @example
+ * this.VE_API.system.ping()
+ */
+import config from "../config";
+
+export default {
+  // 示例接口,可删除或按实际后端替换
+  ping: {
+    url: config.base_dev_url + "health",
+    type: "get",
+  },
+};

+ 126 - 0
src/common/ol_common.js

@@ -0,0 +1,126 @@
+import sourceWMTS from "ol/source/WMTS";
+import WMTSTileGrid from "ol/tilegrid/WMTS";
+import * as olExtent from "ol/extent";
+import TileLayer from "ol/layer/Tile";
+import XYZ from "ol/source/XYZ.js";
+import { WKT } from "ol/format";
+import BufferOp from "jsts/org/locationtech/jts/operation/buffer/BufferOp.js";
+import { OL3Parser } from "jsts/org/locationtech/jts/io";
+import * as geom from "ol/geom";
+import Feature from "ol/Feature";
+
+const jstsParser = new OL3Parser();
+jstsParser.inject(
+  geom.Point,
+  geom.LineString,
+  geom.LinearRing,
+  geom.Polygon,
+  geom.MultiPoint,
+  geom.MultiLineString,
+  geom.MultiPolygon
+);
+
+function CodeToRGB(code) {
+  const result = [];
+  result.push(parseInt(code.substring(1, 3), 16));
+  result.push(parseInt(code.substring(3, 5), 16));
+  result.push(parseInt(code.substring(5), 16));
+  return result;
+}
+
+function createGDWmts() {
+  return new TileLayer({
+    source: new XYZ({
+      url: "http://wprd0{1-4}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&style=7&x={x}&y={y}&z={z}",
+    }),
+    visible: false,
+  });
+}
+
+function crtLayerWMTS(wmtsData, opacity, projection) {
+  const projectionExtent = projection.getExtent();
+  const size = olExtent.getWidth(projectionExtent) / 256;
+  const resolutions = new Array(19);
+  const matrixIds = new Array(19);
+  for (let z = 1; z < 19; ++z) {
+    resolutions[z] = size / Math.pow(2, z);
+    matrixIds[z] = z;
+  }
+
+  const source = new sourceWMTS({
+    url: wmtsData.url,
+    layer: wmtsData.layer,
+    matrixSet: wmtsData.matrixSet,
+    format: "tiles",
+    projection: projection,
+    tileGrid: new WMTSTileGrid({
+      origin: olExtent.getTopLeft(projectionExtent),
+      resolutions: resolutions,
+      matrixIds: matrixIds,
+    }),
+    style: "default",
+    wrapX: true,
+  });
+
+  const layer = new TileLayer({
+    zIndex: wmtsData.zIndex,
+    source: source,
+    visible: false,
+    opacity: opacity ?? 1,
+  });
+  layer.id = wmtsData.layer + "_" + wmtsData.matrixSet;
+  return layer;
+}
+
+function wktCastGeom(wkt) {
+  return new WKT().readGeometry(wkt);
+}
+
+function fit(wkt, view, zoom) {
+  const extent = wktCastGeom(wkt).getExtent();
+  extent[0] = extent[0] / zoom;
+  extent[1] = extent[1] / zoom;
+  extent[2] = extent[2] * zoom;
+  extent[3] = extent[3] * zoom;
+  view.fit(extent, { duration: 700 });
+}
+
+function fitExtent(extent, view, zoom) {
+  const extent2 = [];
+  extent2[0] = extent[0] / zoom;
+  extent2[1] = extent[1] / zoom;
+  extent2[2] = extent[2] * zoom;
+  extent2[3] = extent[3] * zoom;
+  view.fit(extent2, { duration: 700 });
+}
+
+const fitBuffer = (pointWkt, map, meter) => {
+  let geometry = new WKT().readGeometry(pointWkt);
+  const degree = (meter / (2 * Math.PI * 6371004)) * 360;
+  geometry = jstsParser.write(BufferOp.bufferOp(jstsParser.read(geometry), degree, 8));
+  map.getView().fit(geometry, { duration: 1000 });
+};
+
+const newAreaFeature = (data, fieldGeom) => {
+  const geometry = new WKT().readGeometry(data[fieldGeom || "geom"]);
+  const feature = new Feature({ geometry });
+  feature.set("nodeType", "area");
+  feature.setId(data.id);
+  for (const key in data) {
+    if (key != "geom") {
+      feature.set(key, data[key]);
+    }
+  }
+  return feature;
+};
+
+export {
+  crtLayerWMTS,
+  CodeToRGB,
+  createGDWmts,
+  wktCastGeom,
+  fit,
+  fitBuffer,
+  fitExtent,
+  newAreaFeature,
+};

+ 6 - 0
src/config.js

@@ -0,0 +1,6 @@
+/** 修改配置需重启服务后生效 */
+module.exports = {
+  // 预留开关,脚手架默认不启用 mock
+  dev_mock: false,
+  pro_mock: false,
+};

+ 18 - 0
src/main.js

@@ -0,0 +1,18 @@
+import { createApp } from "vue";
+import App from "./App.vue";
+import router from "@/router";
+import axios from "@/plugins/axios";
+import installElementPlus from "@/plugins/element";
+import permission from "@/plugins/permission";
+import "nprogress/nprogress.css";
+import "@/styles/common.scss";
+
+const app = createApp(App);
+
+app
+  .use(installElementPlus)
+  .use(router)
+  .use(axios, { router, opt: "VE_API" })
+  .use(permission, { router });
+
+app.mount("#app");

+ 106 - 0
src/plugins/axios.js

@@ -0,0 +1,106 @@
+"use strict";
+
+import axios from "axios";
+import NProgress from "nprogress";
+import { getToken, clearToken } from "@/utils/auth";
+
+const HTTP_MSG = {
+  400: "请求错误",
+  401: "未授权,请登录",
+  403: "没有权限,拒绝访问",
+  404: "请求地址出错",
+  408: "请求超时",
+  500: "服务器内部错误",
+  501: "服务未实现",
+  502: "网关错误",
+  503: "服务不可用",
+  504: "网关超时",
+  505: "HTTP版本不受支持",
+};
+
+const install = (app, { router, opt = "VE_API" }) => {
+  const _axios = axios.create({ Global: true });
+  let loadingCount = 0;
+
+  _axios.interceptors.request.use(
+    (config) => {
+      NProgress.done();
+      loadingCount++;
+      const token = getToken();
+      if (token) {
+        config.headers.token = token;
+      }
+      return config;
+    },
+    (error) => Promise.reject(error)
+  );
+
+  _axios.interceptors.response.use(
+    (response) => {
+      loadingCount--;
+      if (loadingCount <= 0) {
+        NProgress.done();
+      }
+      return response.data;
+    },
+    (error) => {
+      loadingCount--;
+      if (loadingCount <= 0) {
+        NProgress.done();
+      }
+
+      if (error?.response) {
+        const status = error.response.status;
+        const message = HTTP_MSG[status] || "网络异常";
+        console.error(`[API] ${status}: ${message}`);
+
+        if (status === 401) {
+          clearToken();
+          if (router?.hasRoute?.("Home")) {
+            router.replace({ name: "Home" });
+          }
+        }
+      }
+      return Promise.reject(error);
+    }
+  );
+
+  const method = {
+    post: (url, p, config) => _axios.post(url, p, config),
+    get: (url, p, config) => _axios.get(url, Object.assign(config || {}, { params: p })),
+    put: (url, p, config) => _axios.put(url, p, config),
+    delete: (url, p, config) => _axios.delete(url, config),
+  };
+
+  const api = {};
+  const files = import.meta.glob("@/api/modules/*.js", { eager: true });
+  Object.keys(files).forEach((key) => {
+    const fileName = key.replace(/(\.\/|\.js)/g, "").split("/").pop();
+    api[fileName] = {};
+    const obj = files[key].default || files[key];
+    Object.keys(obj).forEach((item) => {
+      api[fileName][item] = (p, config = {}) => {
+        if (obj[item].mock) {
+          return Promise.resolve(obj[item].response(p));
+        }
+        return method[obj[item].type](pathVariable(obj[item].url, p), p, config);
+      };
+    });
+  });
+
+  window[opt] = api;
+  app.config.globalProperties[opt] = api;
+};
+
+function pathVariable(url, obj) {
+  const prefix = url.indexOf("{");
+  if (prefix > -1) {
+    const suffix = url.indexOf("}");
+    const key = url.substr(prefix + 1, suffix - prefix - 1);
+    url = url.replace("{" + key + "}", obj[key]);
+    return pathVariable(url, obj);
+  }
+  return url;
+}
+
+export default { install };

+ 13 - 0
src/plugins/element.js

@@ -0,0 +1,13 @@
+import ElementPlus from "element-plus";
+import "element-plus/dist/index.css";
+import zhCn from "element-plus/es/locale/lang/zh-cn";
+import "dayjs/locale/zh-cn";
+
+export default {
+  install: (app) => {
+    app.use(ElementPlus, {
+      locale: zhCn,
+      size: "default",
+    });
+  },
+};

+ 28 - 0
src/plugins/permission.js

@@ -0,0 +1,28 @@
+import NProgress from "nprogress";
+import { setToken } from "@/utils/auth";
+
+/**
+ * 简易权限守卫(无 store)
+ * - URL 带 token 时写入本地
+ * - 设置 document.title
+ */
+export default {
+  install: (app, { router }) => {
+    router.beforeEach((to, from, next) => {
+      if (to.query.token) {
+        setToken(to.query.token);
+      }
+
+      if (to.meta?.title) {
+        document.title = to.meta.title;
+      }
+
+      NProgress.start();
+      next();
+    });
+
+    router.afterEach(() => {
+      NProgress.done();
+    });
+  },
+};

+ 47 - 0
src/router/helper.js

@@ -0,0 +1,47 @@
+/**
+ * 路由工厂:统一默认 meta,页面默认开启 keep-alive
+ *
+ * @example
+ * createPage({
+ *   path: '/demo',
+ *   name: 'Demo',
+ *   component: () => import('@/views/demo/index.vue'),
+ *   title: '示例',
+ * })
+ *
+ * // 关闭缓存:
+ * createPage({ ..., keepAlive: false })
+ */
+export function createPage({
+  path,
+  name,
+  component,
+  title = "",
+  keepAlive = true,
+  meta = {},
+  ...rest
+}) {
+  if (!path || !name || !component) {
+    throw new Error("[router] createPage 需要 path / name / component");
+  }
+
+  return {
+    path,
+    name,
+    component,
+    meta: {
+      title,
+      keepAlive,
+      ...meta,
+    },
+    ...rest,
+  };
+}
+
+/** 批量应用 createPage(也可直接传已构造好的 route) */
+export function defineRoutes(list = []) {
+  return list.map((item) => {
+    if (item.meta && item.component) return item;
+    return createPage(item);
+  });
+}

+ 10 - 0
src/router/index.js

@@ -0,0 +1,10 @@
+import { createRouter, createWebHashHistory } from "vue-router";
+import routes from "./routes";
+
+const router = createRouter({
+  history: createWebHashHistory(),
+  scrollBehavior: () => ({ top: 0 }),
+  routes,
+});
+
+export default router;

+ 24 - 0
src/router/routes.js

@@ -0,0 +1,24 @@
+import { createPage } from "./helper";
+
+/**
+ * 页面路由表
+ * - 默认 keepAlive: true,无需每个路由手写
+ * - 不需要缓存时传 keepAlive: false
+ * - 新增页面:在此追加 createPage(...) 即可
+ */
+export default [
+  createPage({
+    path: "/",
+    name: "Home",
+    component: () => import("@/views/home/index.vue"),
+    title: "首页",
+  }),
+
+  // 示例(按需取消注释):
+  // createPage({
+  //   path: "/demo",
+  //   name: "Demo",
+  //   component: () => import("@/views/demo/index.vue"),
+  //   title: "示例页",
+  // }),
+];

+ 34 - 0
src/styles/common.scss

@@ -0,0 +1,34 @@
+html,
+body,
+#app {
+  width: 100%;
+  height: 100%;
+  margin: 0;
+  padding: 0;
+  border: 0;
+}
+
+body {
+  line-height: 24px;
+  font: 14px Helvetica Neue, Helvetica, Tahoma, Arial, sans-serif;
+  background: $main-bg-color;
+  color: #303133;
+  overflow: hidden;
+}
+
+img {
+  vertical-align: middle;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  font-weight: 400;
+}
+
+#nprogress .bar {
+  background: $base-color !important;
+}

+ 12 - 0
src/styles/variables.scss.js

@@ -0,0 +1,12 @@
+const variables = {
+  main_bg_color: "#f5f5f5",
+  base_color: "#409EFF",
+  nav_height: "80px",
+  side_close_width: "65px",
+  side_open_width: "160px",
+  sideBgColor: "#121213",
+  sideTextColor: "#fff",
+  sideActiveTextColor: "#ffd04b",
+};
+
+export default variables;

+ 16 - 0
src/utils/auth.js

@@ -0,0 +1,16 @@
+const TOKEN_KEY = "token";
+
+export function getToken() {
+  return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY) || "";
+}
+
+export function setToken(token) {
+  if (!token) return;
+  localStorage.setItem(TOKEN_KEY, token);
+  sessionStorage.setItem(TOKEN_KEY, token);
+}
+
+export function clearToken() {
+  localStorage.removeItem(TOKEN_KEY);
+  sessionStorage.removeItem(TOKEN_KEY);
+}

+ 37 - 0
src/views/home/index.vue

@@ -0,0 +1,37 @@
+<template>
+  <div class="scaffold-home">
+    <h1>feiniao</h1>
+    <p>脚手架已就绪,在 <code>src/router/routes.js</code> 添加页面路由即可。</p>
+  </div>
+</template>
+
+<script setup>
+// 业务页面在 views 下新建,并通过 createPage 注册到 routes.js
+</script>
+
+<style lang="scss" scoped>
+.scaffold-home {
+  box-sizing: border-box;
+  min-height: 100%;
+  padding: 48px 24px;
+  background: linear-gradient(180deg, #eef5ff 0%, $main-bg-color 40%);
+
+  h1 {
+    margin: 0 0 12px;
+    font-size: 28px;
+    color: #1f2d3d;
+  }
+
+  p {
+    margin: 0;
+    color: #606266;
+  }
+
+  code {
+    padding: 2px 6px;
+    border-radius: 4px;
+    background: #fff;
+    color: $base-color;
+  }
+}
+</style>

+ 78 - 0
vite.config.js

@@ -0,0 +1,78 @@
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import { resolve } from "path";
+import envConfig from "./env.config.js";
+import scssVariables from "./src/styles/variables.scss.js";
+
+const scssAdditionalData = Object.keys(scssVariables)
+  .map((k) => `$${k.replace("_", "-")}: ${scssVariables[k]};`)
+  .join("\n");
+
+export default defineConfig(({ mode }) => {
+  const currentEnv = envConfig[mode] || envConfig.development;
+
+  return {
+    plugins: [vue()],
+    resolve: {
+      alias: {
+        "@": resolve(__dirname, "src"),
+      },
+      extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".vue"],
+    },
+    css: {
+      preprocessorOptions: {
+        scss: {
+          additionalData: scssAdditionalData,
+        },
+      },
+    },
+    server: {
+      host: "127.0.0.1",
+      port: 8082,
+      open: false,
+      proxy: {
+        "/ws": {
+          target: "https://apis.map.qq.com",
+          changeOrigin: true,
+          ws: false,
+        },
+        "/v3/elevation": {
+          target: "https://restapi.amap.com",
+          changeOrigin: true,
+          ws: false,
+        },
+      },
+    },
+    build: {
+      target: "es2020",
+      outDir: "dist",
+      assetsDir: "assets",
+      sourcemap: false,
+      minify: "esbuild",
+      rollupOptions: {
+        output: {
+          chunkFileNames: "js/[name]-[hash].js",
+          entryFileNames: "js/[name]-[hash].js",
+          assetFileNames: "[ext]/[name]-[hash].[ext]",
+          manualChunks: {
+            vue: ["vue", "vue-router"],
+            elementPlus: ["element-plus"],
+            ol: ["ol"],
+          },
+        },
+      },
+    },
+    define: {
+      VE_ENV: {
+        MODE: JSON.stringify(currentEnv.NODE_ENV),
+        SERVER: JSON.stringify(currentEnv.SERVER),
+        PYSERVER: JSON.stringify(currentEnv.PYSERVER),
+        NEW_SERVER: JSON.stringify(currentEnv.NEW_SERVER),
+      },
+      __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
+    },
+    optimizeDeps: {
+      include: ["vue", "vue-router", "element-plus", "axios", "dayjs", "ol"],
+    },
+  };
+});