前端WEB开发
前置准备
为了更好的开发体验,我们提供了一些工具配置、项目说明,以便于您更好的开发。本项目需要一定前端基础知识,请确保掌握 Vue 的基础知识,以便能处理一些常见的问题。建议在开发前先学一下以下内容,提前了解和学习这些知识,会对项目理解非常有帮助:
- Vue3 (https://vuejs.org/)
- Tailwind CSS (https://tailwindcss.com/)
- TypeScript (https://www.typescriptlang.org/)
- Vue Router (https://router.vuejs.org/)
- Vitejs (https://vitejs.dev/)
- Pnpm (https://pnpm.io/)
- Turbo (https://turbo.build/)
npm scripts
{
"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 代码如下:
<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的代码如下:
<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目录下增删文件,即可自动添加更新路由
配置内容是一个对应菜单层级的可嵌套的数组,如:
[
{
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的指令可以简单直接的使用:
<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
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目录中新增您使用的接口,并在页面中引入接口使用即可。
// 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
项目构建
如果你是第一个拉取代码,建议你完成一次【安装】【运行】,看看页面是否正常。
pnpm install
pnpm dev然后你就可以了走下一步了。
npm run build该命令会生成 dist目录,用于存放构建生成的所有的静态资源。
npm run preview该命令会在本地启动一个静态 Web 服务器,将 dist 文件夹作为服务器的静态资源运行,这样就可以在本地环境查看构建产物是否正常。 可通过 --port参数来配置服务的运行端口。
tip: 预览服务器不支持热重载,静态资源有改动,需重新打包,并重启预览服务器。
发布与部署
类似其他VUE项目一样,VUE实际上生成的是一个完全静态的html网站,你可以仔细研究一下项目构建生成的dist目录。
我们建议采取nginx独立部署,首先你必须安装一个nginx服务,这个你可以参考官方文档或者其他资料,假设目录在:
D:\mes-apps\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
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: trueJenkins脚本参考:
#!/bin/bash
set -e # 出错立即退出