Skip to content

前端WEB开发

前置准备

为了更好的开发体验,我们提供了一些工具配置、项目说明,以便于您更好的开发。本项目需要一定前端基础知识,请确保掌握 Vue 的基础知识,以便能处理一些常见的问题。建议在开发前先学一下以下内容,提前了解和学习这些知识,会对项目理解非常有帮助:

npm scripts

json
{
  "scripts": {
    "dev": "vite --open --mode development",
    "build": "vue-tsc --noEmit && vite build --mode production",
    "preview": "vite preview",
    "lint": "eslint --ext .vue,.js,.jsx,.ts,.tsx ./ --max-warnings 10",
    "lint:fix": "eslint --ext .vue,.js,jsx,.ts,.tsx ./ --max-warnings 10 --fix",
    "stylelint": "stylelint src/**/*.{html,vue,css,sass,less}",
    "stylelint:fix": "stylelint --fix src/**/*.{html,vue,css,sass,less}",
    "prepare": "node -e \"if(require('fs').existsSync('.git')){process.exit(1)}\" || is-ci || husky install",
    "clean": "node ./scripts/clean.mjs",
    "reinstall": "pnpm clean --del-lock && pnpm install"
  }
}

环境变量

在项目的根目录,有 .env 配置文件,项目会根据启动的命令中的 mode 参数,加载指定的配置文件的配置来运行, 如本地环境执行 npm run dev,因为对于命令中的mode 参数为development,项目运行会加载.env.development的配置来运行。 项目初始化内置了 .env.development、.env.test 和 .env 分别对应本地开发环境、测试环境 和 生产(正式)环境,也可以根据实际需求继续扩展。内置的环境变量如下:

  • VITE_BASE_URL:项目启动运行默认的 URL
  • VITE_IS_REQUEST_PROXY: 项目是否启动请求代理
  • VITE_API_URL: 项目默认请求的 URL
  • VITE_API_URL_PREFIX:项目默认请求的前缀

新增页面

按照规则我们建议一个功能页面是一个文件夹,比如

src\pages\basic\bas002\

在文件夹里面新建一个index.vue文件,就是这个功能的主画面;

如果有其他的组件,或者弹出窗口,编辑窗口等,我们建议在

src\pages\basic\bas002\components

里面创建orderModal.vue

src\pages\basic\bas002\index.vue 代码如下:

vue
<script setup lang="ts">
/**
 * 部门管理
 */
import type { ColumnDefine } from '@visactor/vtable';
import { FilterPlugin } from '@visactor/vtable-plugins';
import type { VTable } from '@visactor/vue-vtable';
import { ListTable } from '@visactor/vue-vtable';
import { MessagePlugin } from 'tdesign-vue-next';
import { ref } from 'vue';
import { deptApi } from '@/api/basic/request';
import type { TbbDeptList } from '@/api/basic/types';
import { hmxVTable, tableInstance } from '@/hooks/modules/vtable';
import { formatDateTime } from '@/utils/formatters';
import OrderModal from './components/orderModal.vue';

// 表格筛选
const filterPlugin = new FilterPlugin({});
const orderRecords = ref<Partial<TbbDeptList>[]>([]);

// 查询条件
const searchForm = ref<Partial<TbbDeptList>>({});

// 表格初始化选项
const showOrderModal = ref(false);
const isEditMode = ref(false);
const currentRowData = ref<Partial<TbbDeptList>>({});

// 表格字段
const columns: ColumnDefine[] = [
  { field: 'cDeptCode', title: '部门编码', sort: true },
  { field: 'cDeptName', title: '部门名称', sort: true },
  {
    field: 'nStatus',
    title: '状态',
    fieldFormat: (data: any) => {
      const statusMap: Record<string, string> = {
        '1': '有效',
        '2': '无效',
      };
      return statusMap[data.nStatus] || '未知';
    },
  },
  { field: 'creator', title: '创建人' },
  {
    field: 'createTime',
    title: '创建时间',
    sort: true,
    fieldFormat: (data: any) => formatDateTime(data.createTime),
  },
  { field: 'lastModifier', title: '最后更新人' },
  {
    field: 'lastModifyTime',
    title: '最后更新时间',
    sort: true,
    fieldFormat: (data: any) => formatDateTime(data.lastModifyTime),
  },
];

const listTableRef = ref<null | VTable.ListTable>(null);
const listOptions = hmxVTable.use(listTableRef, columns, {
  widthMode: 'autoWidth',
  frozenColCount: 4,
  dragOrder: {
    dragHeaderMode: 'all',
  },
  plugins: [filterPlugin],
});

async function handleSearch() {
  try {
    const searchParams = Object.fromEntries(
      Object.entries(searchForm.value).filter(([_, v]) => v !== undefined),
    ) as Partial<TbbDeptList>;
    const res = await deptApi.queryDept(searchParams as TbbDeptList);
    orderRecords.value = res as Partial<TbbDeptList>[];
  } catch (error: any) {
    MessagePlugin.error(`网络错误,请检查接口或网络: ${error.message}`);
  }
}

function handleAdd() {
  const row = tableInstance(listTableRef).getFocusedRow();
  isEditMode.value = false;
  currentRowData.value = { ...row };
  showOrderModal.value = true;
}

function handleEdit() {
  const row = tableInstance(listTableRef).getFocusedRow() as TbbDeptList;
  if (!row) {
    MessagePlugin.warning('请先选择一条部门记录');
    return;
  }
  isEditMode.value = true;
  currentRowData.value = { ...row };
  console.log(currentRowData.value);
  showOrderModal.value = true;
}

async function handleSave(data: Partial<TbbDeptList>) {
  try {
    if (isEditMode.value) {
      const originalData = currentRowData.value;
      const hasChanged = Object.keys(data).some((key) => {
        const k = key as keyof TbbDeptList;
        if (k === 'createTime' || k === 'lastModifyTime' || k === 'creator' || k === 'lastModifier') return false;
        return data[k] !== originalData[k];
      });
      if (!hasChanged) {
        MessagePlugin.info('数据未发生变更');
        return;
      }
      await deptApi.updateDept(data as TbbDeptList);
      MessagePlugin.success('修改成功');
    } else {
      await deptApi.addDept(data as TbbDeptList);
      MessagePlugin.success('新增成功');
    }
    showOrderModal.value = false;
    await handleSearch();
  } catch (error: any) {
    const errMsg = error.response?.data?.message || error.message || '未知错误';
    MessagePlugin.error(`保存失败:${errMsg}`);
  }
}
</script>

<template>
  <page auto-content-height>
    <t-row justify="space-between">
      <t-form label-align="left" label-width="100px">
        <t-space>
          <t-form-item label="部门名称">
            <t-input v-model="searchForm.cDeptName" placeholder="部门名称" class="w-40" clearable />
          </t-form-item>
          <t-form-item label="部门编码">
            <t-input v-model="searchForm.cDeptCode" placeholder="部门编码" class="w-40" clearable />
          </t-form-item>
          <t-button class="w-full" @click="handleSearch">
            <template #icon><div class="t-icon icon-[raphael--search]" /></template>
            查询
          </t-button>
          <t-button class="w-full" variant="outline" @click="handleAdd">
            <template #icon><div class="t-icon icon-[material-symbols--add-circle]" /></template>
            新增部门
          </t-button>
          <t-button class="w-full" @click="handleEdit">
            <template #icon><div class="t-icon icon-[material-symbols--edit-square]" /></template>
            编辑
          </t-button>
        </t-space>
      </t-form>
    </t-row>
    <t-row class="h-full">
      <list-table ref="listTableRef" :options="listOptions" :records="orderRecords" />
    </t-row>
    <order-modal
      v-model="showOrderModal"
      :is-edit="isEditMode"
      :row-data="currentRowData"
      draggable
      @save="handleSave"
      @update:show="showOrderModal = $event"
    />
  </page>
</template>

src\pages\basic\bas002\components\orderModal.vue的代码如下:

vue
<script setup lang="ts">
import type { FormInstanceFunctions } from 'tdesign-vue-next';
import { computed, reactive, ref, watch } from 'vue';
import type { TbbDeptList } from '@/api/basic/types';

/**
 * 部门管理编辑弹窗
 */
// 定义 props
interface Props {
  modelValue?: boolean;
  isEdit?: boolean;
  rowData?: Partial<TbbDeptList>;
}

const props = withDefaults(defineProps<Props>(), {
  modelValue: false,
  isEdit: false,
  rowData: () => ({}),
});

// 定义事件
const emit = defineEmits<{
  (e: 'update:modelValue', value: boolean): void;
  (e: 'save', data: Partial<TbbDeptList>): void;
}>();

// 表单引用
const formRef = ref<FormInstanceFunctions | null>(null);

// 表单数据
const formModel = reactive<Partial<TbbDeptList>>({});
const showProxy = computed({
  get() {
    return props.modelValue;
  },
  set(value) {
    emit('update:modelValue', value);
  },
});

// 监听 props.rowData回填数据
watch(
  () => props.rowData,
  (val) => {
    if (val) {
      Object.assign(formModel, val);
      // 如果是新增模式,清除系统管理的字段
      if (!props.isEdit) {
        formModel.creator = undefined;
        formModel.createTime = undefined;
        formModel.lastModifier = undefined;
        formModel.lastModifyTime = undefined;
      }
    } else {
      Object.keys(formModel).forEach((key) => {
        (formModel as any)[key] = undefined;
      });
    }
  },
  { immediate: true },
);

// 保存
function handleSave() {
  formRef.value?.validate().then((result) => {
    if (result === true) {
      emit('save', { ...formModel });
    }
  });
}

// 关闭弹窗
function handleClose() {
  emit('update:modelValue', false);
}

// 状态选项
const statusOptions = [
  { label: '有效', value: 1 },
  { label: '无效', value: 0 },
];
</script>

<template>
  <t-dialog
    v-model:visible="showProxy"
    :header="isEdit ? '编辑部门' : '新增部门'"
    :close-on-overlay-click="false"
    draggable
    width="30vw"
    @confirm="handleSave"
    @cancel="handleClose"
    <t-form ref="formRef" label-align="left" label-width="100px" class="w-full">
      <div class="flex flex-col gap-y-1 mb-4">
        <t-form-item label="部门编码" name="cDeptCode">
          <t-input v-model="formModel.cDeptCode" placeholder="部门编码" />
        </t-form-item>
        <t-form-item label="部门名称" name="cDeptName">
          <t-input v-model="formModel.cDeptName" placeholder="部门名称" />
        </t-form-item>
        <t-form-item label="状态" name="nStatus">
          <t-select v-model="formModel.nStatus" placeholder="状态" :options="statusOptions" :default-value="1" />
        </t-form-item>
      </div>
    </t-form>
  </t-dialog>
</template>

这里的代码看上去很复杂,实际上都是有规律可循,如果你具备VUE基础知识,以及会使用对应的第三方组件,再结合AI工具生成,还是比较简单的。

路由和菜单

首页你要理解VUE

路由与菜单的管理,是前端项目中非常重要的一部分。

为了减少开发配置和理解成本,在 TDesign Starter 项目中,管理菜单路由都规范在src/router 这个目录下进行配置。

tips: 通常情况下不需要去理解和修改index.ts, 只需要在modules目录下增删文件,即可自动添加更新路由

配置内容是一个对应菜单层级的可嵌套的数组,如:

ts
[
  {
    path: "/list",
    name: "list",
    component: Layout,
    redirect: "/list/base",
    meta: { title: "列表页", icon: ListIcon, expanded: true },
    children: [
      {
        path: "base",
        name: "ListBase",
        component: () => import("@/pages/list/base/index.vue"),
        meta: { title: "基础列表页", orderNo: 0 },
      },
      {
        path: "card",
        name: "ListCard",
        component: () => import("@/pages/list/card/index.vue"),
        meta: { title: "卡片列表页", hiddenBreadcrumb: true },
      },
      {
        path: "filter",
        name: "ListFilter",
        component: () => import("@/pages/list/filter/index.vue"),
        meta: { title: "筛选列表页" },
      },
      {
        path: "tree",
        name: "ListTree",
        component: () => import("@/pages/list/tree/index.vue"),
        meta: { title: "树状筛选列表页" },
      },
    ],
  },
];

数组中每个配置字段都有具体的作用:

  • path 是当前路由的路径,会与配置中的父级节点的 path 组成该页面路由的最终路径;如果需要跳转外部链接,可以将path设置为 http 协议开头的路径。
  • name 影响多标签 Tab 页的 keep-alive 的能力,如果要确保页面有 keep-alive 的能力,请保证该路由的name与对应页面(SFC)的name保持一致。
  • component 渲染该路由时使用的页面组件
  • redirect 重定向的路径
  • meta 主要用途是路由在菜单上展示的效果的配置
  • meta.title 该路由在菜单上展示的标题
  • meta.icon 该路由在菜单上展示的图标
  • meta.expanded 决定该路由在菜单上是否默认展开
  • meta.orderNo 该路由在菜单上展示先后顺序,数字越小越靠前,默认为零
  • meta.hidden 决定该路由是否在菜单上进行展示
  • meta.hiddenBreadcrumb 如果启用了面包屑,决定该路由是否在面包屑上进行展示
  • meta.single 如果是多级菜单且只存在一个节点,想在菜单上只展示一级节点,可以使用该配置。请注意该配置需配置在父节点
  • meta.frameSrc 内嵌 iframe 的地址
  • meta.frameBlank 内嵌 iframe 的地址是否以新窗口打开
  • meta.keepAlive 可决定路由是否开启keep-alive,默认开启。
  • children 子菜单的配置

由于 TDesign 菜单的限制,最多只允许配置到三级菜单。如果菜单层级超过三级,我们建议梳理业务场景,判断层级是否合理。

基于此,hmx自动获取了后端的权限对应的菜单,自动生成了前端对应的路由。

您只需要配置后台的菜单即可。

权限控制

权限控制目前分为两个部分,一个是菜单,实际上是由后端生成的动态路由,另外一个是前端的组件权限控制,比如控制某个按钮不可见。这里只说关于按钮的权限控制,其他组件亦可。

在这里配置好要受控的组件【按钮】,注意【编码】就是前后端对应的匹配点。

在你的代码参考如下写法,我们设计了一个v-hp的指令可以简单直接的使用:

vue
<template>
  <page auto-content-height>
    <t-row justify="space-between">
      <t-space>
        <t-input v-model="keywords" />
        <t-button theme="primary" @click="btnQuery"> 查询 </t-button>
      </t-space>
      <t-space>
        <t-button v-hp="'btnAdd'" variant="outline" @click="btnNew">
          <template #icon><div class="t-icon icon-[material-symbols--add]" /></template>
          新增
        </t-button>
        <t-button v-hp="'btnEdit'" variant="outline" @click="btnEdit">
          <template #icon>
            <div class="t-icon icon-[line-md--edit]" />
          </template>
          编辑
        </t-button>
        <t-button v-hp="'btnDelete'" variant="outline" @click="btnDelete">
          <template #icon>
            <div class="t-icon icon-[proicons--delete]" />
          </template>
          删除
        </t-button>
……

请求与数据

我们的后台服务是全部自动生成前端的请求与接口的,您可以按以下关键点生成:

生成完了,您可以得到三个文件,类型接口、枚举接口、服务接口,放入服务对应的文件夹,比如:src\api\admin

如下图:

然后我们就可以在前端的页面直接使用了:

src\pages\admin\user\index.vue

ts
import { adminApi } from '@/api/admin/request';
import type { HmxUser } from '@/api/admin/types';

async function btnQuery() {
  const users = await adminApi.getUsers(keywords.value);
  listRecords.value = users ?? undefined;
}

当然这些简单的应用都是因为hmx完全封装了细节,我们采用 axios 做为请求的资源库,并对其做了封装,可以从src/utils/request的路径中引入封装的 request,并在具体场景中使用。我们建议您在src/apis目录中管理您的项目使用到的 api,并在具体组件/页面中使用。 大部分情况下,您不需要改动src/utils/request中的代码,只需要在src/apis目录中新增您使用的接口,并在页面中引入接口使用即可。

ts
// src/apis 管理api请求
import { request } from "@/utils/request";
import type { CardListResult, ListResult } from "@/api/model/listModel";

const Api = {
  BaseList: "/get-list",
  CardList: "/get-card-list",
};

export function getList() {
  return request.get<ListResult>({
    url: Api.BaseList,
  });
}

export function getCardList() {
  return request.get<CardListResult>({
    url: Api.CardList,
  });
}

其他更多内容,你可以参考vue或者腾讯官方文档:

https://tdesign.tencent.com/starter/docs/vue-next/request-data

项目构建

如果你是第一个拉取代码,建议你完成一次【安装】【运行】,看看页面是否正常。

bash
pnpm install

pnpm dev

然后你就可以了走下一步了。

bash
npm run build

该命令会生成 dist目录,用于存放构建生成的所有的静态资源。

bash
npm run preview

该命令会在本地启动一个静态 Web 服务器,将 dist 文件夹作为服务器的静态资源运行,这样就可以在本地环境查看构建产物是否正常。 可通过 --port参数来配置服务的运行端口。

tip: 预览服务器不支持热重载,静态资源有改动,需重新打包,并重启预览服务器。

发布与部署

类似其他VUE项目一样,VUE实际上生成的是一个完全静态的html网站,你可以仔细研究一下项目构建生成的dist目录。

我们建议采取nginx独立部署,首先你必须安装一个nginx服务,这个你可以参考官方文档或者其他资料,假设目录在:

D:\mes-apps\nginx

参考如下的配置文件:

nginx
#user nobody;
worker_processes 1;
error_log logs/error.log;

events {
  worker_connections 1024;
}

http {
  include mime.types;
  default_type application/octet-stream;

  #log_format main '$remote_addr - $remote_user [$time_local] "$request" '
  # '$status $body_bytes_sent "$http_referer" '
  # '"$http_user_agent" "$http_x_forwarded_for"';
  #access_log logs/access.log main;

  sendfile on;
  keepalive_timeout 65;

  server {
    listen 9528;
    server_name localhost;
    root D:\mes-apps\nginx\html\rmes;
    index index.html;

    # 开启gzip压缩
    gzip on;
    gzip_min_length 1k;
    gzip_comp_level 2;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

    # 处理 Vue 路由的 history 模式
    location / {
      try_files $uri $uri/ /index.html;
      # 不要删除,是为了辨别MES系统的
      add_header X-System-Identifier "HmxWEB" always;
    }

    # API 反向代理设置
    location /api/ {
      proxy_pass http://127.0.0.1:9525/api/; # 替换为你的API实际地址
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

      # 处理跨域
      add_header 'Access-Control-Allow-Origin' 'http://127.0.0.1:9525' always;
      add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
      add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;

      if ($request_method = 'OPTIONS') {
        return 204;
      }
    }

    # 静态资源缓存
    location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
      expires 1y;
      access_log off;
      add_header Cache-Control "public";
    }

    # 禁用 .env 文件访问
    location ~ /\.env {
      deny all;
      return 404;
    }
  }
}

注意里面的端口和目录,把dist目录复制到D:\mes-apps\nginx\html\rmes就可以了。

至于怎么由拉取代码自动发布到D:\mes-apps\nginx\html\rmes目录,可以使用前面的devops的脚本进行。

azure-pipelines-PRD.yml

yaml
trigger: none

pool:
  name: default

variables:
  - group: deploy-secrets-PRD

stages:
  - stage: Build
    displayName: 编译构建
    jobs:
      - job: BuildJob
        displayName: 编译 MES-WEB 项目
        steps:
          - task: NodeTool@0
            displayName: 'Install Node.js'
            inputs:
              versionSpec: '22.x'
          - script: npm install -g pnpm
            displayName: 'Install pnpm globally'
          - script: |
              echo 设置 PNPM 缓存路径
              set PNPM_STORE_PATH=$(Agent.WorkFolder)\_pnpm-store
              pnpm config set store-dir %PNPM_STORE_PATH%
            displayName: 设置 pnpm 缓存路径
          - script: pnpm install
            displayName: 'Install dependencies with pnpm'
          - script: pnpm run build
            displayName: 'Build hmx web app'
          - task: PublishBuildArtifacts@1
            displayName: '发布构建产物'
            inputs:
              PathtoPublish: '$(Build.SourcesDirectory)\dist'
              ArtifactName: 'drop'
              publishLocation: 'Container'
  - stage: Deploy
    displayName: 部署所有服务
    dependsOn: Build
    jobs:
      - job: DeployServer1Service
        displayName: 部署所有服务A&B
        steps:
          - task: DownloadBuildArtifacts@0
            displayName: '下载构建产物'
            inputs:
              buildType: 'current'
              downloadType: 'single'
              artifactName: 'drop'
              downloadPath: '$(Build.SourcesDirectory)/output'
          - task: WindowsMachineFileCopy@1
            displayName: '复制主程序到 A 服务器'
            inputs:
              SourcePath: '$(Build.SourcesDirectory)/output/drop'
              TargetPath: '$(MES_ROOT_PATH)\nginx\html\rmes'
              EnvironmentName: '$(SERVER_A_IP)'
              AdminUserName: '$(SERVER_A_USER)'
              AdminPassword: '$(SERVER_A_PWD)'
              CleanTargetBeforeCopy: true
          - task: WindowsMachineFileCopy@1
            displayName: '复制主程序到 B 服务器'
            inputs:
              SourcePath: '$(Build.SourcesDirectory)/output/drop'
              TargetPath: '$(MES_ROOT_PATH)\nginx\html\rmes'
              EnvironmentName: '$(SERVER_B_IP)'
              AdminUserName: '$(SERVER_B_USER)'
              AdminPassword: '$(SERVER_B_PWD)'
              CleanTargetBeforeCopy: true

Jenkins脚本参考:

bash
#!/bin/bash

set -e # 出错立即退出

HiMind 工业互联网平台 技术文档