前端开发规范
前端位于 lemon-web,使用 Vue 3 组合式 API(<script setup>)+ Ant Design Vue 4。本页介绍页面的标准写法,新页面请参照现有页面保持一致的交互。
目录与命名
| 位置 | 内容 |
|---|---|
src/api/<模块>/<功能>.js | 接口函数,基于 @/utils/request |
src/views/<模块>/<页面>/index.vue | 菜单页面,路径即菜单的「组件路径」(wms/warehouse/index) |
src/components/<模块>/ | 本模块的单据表单(XxxForm.vue)、详情(XxxDetail.vue)等组件 |
src/components/ | 通用组件:TableToolbar、SearchForm、FormDrawer、SubPage、DictTag、StatusSwitch、AttachmentPanel、ChangeLogPanel、ImportModal |
src/components/common/ | LineItemsTable(单据明细行表格)、CustomFieldsForm、CustomFieldsView(自定义字段的输入与只读显示) |
src/components/print/ | PrintPreview(全局打印预览窗口)、OverlayDesigner(套打设计器) |
src/composables/ | useTable、useColumns、useFormDrawer、useDict、useAction(单条操作)、useLineItems(明细行)、useCustomFields |
src/constants/enums.js | 由后端枚举生成的状态、类型常量(npm run gen:enums),见 状态与类型常量 |
src/utils/number.js | 金额、数量的格式化与舍入(money、qty、round2 等),见 数字的显示与舍入 |
src/locales/<语言>/<模块>.js | 界面文字的语言包(zh-CN、en-US,键一致),en-US/menu.js、dict.js 是菜单和字典的英文译文,见 国际化 |
接口封装
// src/api/wms/warehouse.js
import request from '@/utils/request'
const ids = (v) => [].concat(v).join(',')
export const pageWarehouse = (params) => request.get('/wms/warehouse', { params })
export const getWarehouse = (id) => request.get(`/wms/warehouse/${id}`)
export const addWarehouse = (data) => request.post('/wms/warehouse', data)
export const updateWarehouse = (id, data) => request.put(`/wms/warehouse/${id}`, data)
export const changeWarehouseStatus = (id, status) => request.put(`/wms/warehouse/${id}/status`, { status })
export const deleteWarehouse = (id) => request.delete(`/wms/warehouse/${ids(id)}`)request 的 baseURL 为 /api,超时 15 秒。响应拦截器的处理:
code === 200:直接返回data(页面中const page = await pageWarehouse(params)拿到的就是{ records, total });code === 401或 HTTP 401:清除令牌,提示「登录已过期」后跳转登录页;- 其他错误:
message.error显示后端的msg,并给错误对象打上handled标记后 reject。
请求拦截器给每个请求加上 Accept-Language: <界面语言>(zh-CN / en-US),后端据此返回对应语言的 msg,所以页面直接显示后端的提示即可,不需要自己翻译。
因此页面中不需要 catch 来提示错误,用 try / finally 恢复 loading 状态即可;main.js 的全局错误处理会忽略已提示过的错误。
async function remove(id) {
await deleteWarehouse(id) // 失败时拦截器已提示,后续代码不会执行
message.success(t('common.msg.deleteSuccess'))
load()
}这种「调接口 → 提示成功 → 刷新」的单条操作一般直接用 useAction 生成。
文件下载用 responseType: 'blob',成功时返回 Blob,失败时拦截器会把 JSON 错误解析出来提示。
列表页
列表页参照 views/system/user/index.vue 或 views/wms/warehouse/index.vue,结构固定:
<template>
<div class="table-page">
<a-card v-show="showSearch" :bordered="false" class="page-card">
<SearchForm :model="query" @search="search" @reset="resetQuery">
<a-form-item :label="$t('wms.common.warehouse')" name="keyword">
<a-input v-model:value="query.keyword" :placeholder="$t('wms.flow.codeOrName')" allow-clear />
</a-form-item>
<a-form-item :label="$t('common.field.status')" name="status">
<a-select v-model:value="query.status" :placeholder="$t('common.placeholder.all')" allow-clear :options="toOptions(dict.sys_common_status, true)" />
</a-form-item>
</SearchForm>
</a-card>
<a-card :bordered="false" class="page-card table-card">
<TableToolbar
:exporter="exportAll"
v-model:size="size"
v-model:checked="checked"
v-model:show-search="showSearch"
:columns="columns"
@refresh="load"
>
<a-button v-perm="'wms:warehouse:add'" type="primary" @click="drawer.openCreate()">
<template #icon><PlusOutlined /></template>{{ $t('wms.warehouse.createTitle') }}
</a-button>
</TableToolbar>
<a-table
row-key="id"
:size="size"
:columns="visibleColumns"
:data-source="list"
:loading="loading"
:pagination="pagination"
:scroll="{ x: 1200, y: '100%' }"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<DictTag type="wms_warehouse_type" :value="record.type" />
</template>
<template v-else-if="column.key === 'action'">
<span class="table-actions">
<a-button v-perm="'wms:warehouse:edit'" type="link" size="small" @click="drawer.openEdit(record)">{{ $t('common.action.edit') }}</a-button>
</span>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { PlusOutlined } from '@ant-design/icons-vue'
import DictTag from '@/components/DictTag.vue'
import SearchForm from '@/components/SearchForm.vue'
import TableToolbar from '@/components/TableToolbar.vue'
import { pageWarehouse } from '@/api/wms/warehouse'
import { useColumns } from '@/composables/useColumns'
import { useDict } from '@/composables/useDict'
import { useTable } from '@/composables/useTable'
import { toOptions } from '@/utils/dict'
const { t } = useI18n()
const dict = useDict('sys_common_status', 'wms_warehouse_type')
// 切换语言会刷新整个页面,列定义直接用 t(),不需要 computed
const columns = [
{ title: t('wms.common.warehouse'), key: 'name', width: 200 },
{ title: t('common.field.type'), key: 'type', width: 150, dict: 'wms_warehouse_type' },
{ title: t('wms.warehouse.address'), dataIndex: 'address', width: 240, ellipsis: true },
{ title: t('common.action.operation'), key: 'action', width: 220, fixed: 'right' }
]
const { checked, visibleColumns } = useColumns(columns, 'wms-warehouse')
const showSearch = ref(true)
const query = reactive({ keyword: undefined, status: undefined })
const { list, loading, size, pagination, load, search, onTableChange, exportAll } = useTable(pageWarehouse, query)
function resetQuery() {
Object.assign(query, { keyword: undefined, status: undefined })
search()
}
onMounted(load)
</script>要点:
- 根元素
class="table-page",表格所在卡片加table-card,表格设置:scroll="{ x: 总宽度, y: '100%' }":表头固定,只有表格区域滚动,分页条始终可见; TableToolbar提供刷新、密度、列设置、显示 / 隐藏搜索栏、导出,默认插槽放操作按钮,extra插槽放右侧附加按钮;useColumns(columns, 存储键)记住用户隐藏的列(只存隐藏的列,以后新增的列默认显示);- 列定义加
dict: '字典类型'后导出 Excel 时输出字典标签,或用exportValue: (record) => 值自定义; - 操作列
key: 'action'、fixed: 'right',按钮放在span.table-actions中; - 页面上的文字全部用
$t/t,列title同时用作导出 Excel 的表头,导出也会跟随界面语言。
useTable
const { list, loading, size, pagination, selectedKeys, rowSelection, load, search, onTableChange, exportAll } =
useTable(fetcher, query, { params: (q) => ({ ...q, beginTime: q.range?.[0], endTime: q.range?.[1], range: undefined }) })| 返回值 | 说明 |
|---|---|
list / loading / pagination | 绑定到 a-table |
load() | 按当前页重新加载;快速切换条件时只采用最后一次请求的结果;删除最后一页的全部数据后自动回到上一页 |
search() | 回到第 1 页并加载 |
onTableChange | 绑定 @change,处理翻页、改每页条数 |
selectedKeys / rowSelection | 多选 |
exportAll(columns, filename?) | 按当前条件导出 Excel,传给 TableToolbar 的 exporter |
fetcher 接收 { current, size, ...query },返回 { records, total }。options.params 用于把查询条件转换为接口参数(如把时间范围拆成两个参数)。
表单:useFormDrawer + FormDrawer
新增 / 编辑表单统一用 useFormDrawer 管理状态,FormDrawer 渲染外壳(标题、保存 / 取消按钮、加载状态):
<FormDrawer :drawer="drawer" :create-title="$t('wms.warehouse.createTitle')" :edit-title="$t('wms.warehouse.editTitle')">
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-form-item :label="$t('wms.warehouse.code')" name="code">
<a-input v-model:value="form.code" :maxlength="32" />
</a-form-item>
<a-form-item :label="$t('wms.warehouse.name')" name="name">
<a-input v-model:value="form.name" :maxlength="64" />
</a-form-item>
</a-form>
</FormDrawer>const rules = {
code: [{ required: true, message: t('wms.warehouse.inputCode') }],
name: [{ required: true, whitespace: true, message: t('wms.warehouse.inputName') }]
}
const drawer = useFormDrawer({
emptyForm: () => ({ id: undefined, code: '', name: '', type: 'NORMAL', remark: '' }),
fetch: (record) => getWarehouse(record.id), // 编辑时加载详情,不传则直接用表格行数据
create: addWarehouse,
update: updateWarehouse,
toPayload: ({ code, name, type, remark }) => ({ code, name, type, remark }), // 可选,默认去掉 id 和只读字段
onSuccess: load
})
const { formRef, form } = drawer| 选项 | 说明 |
|---|---|
emptyForm | 返回空表单(必填) |
beforeOpen(record) | 打开前加载下拉选项等依赖数据,新增时 record 为 null |
fetch(record) | 编辑时加载详情 |
create(payload) / update(id, payload) | 保存,按表单是否有 id 调用 |
toPayload(form) | 提交数据,默认去掉 id、createTime、updateTime、children |
onSuccess | 保存成功后回调,一般用于刷新列表 |
successMessage | 默认 t('common.msg.saveSuccess')(「保存成功」) |
自带的行为:打开时加载详情(失败自动关闭)、保存前校验、有未保存的修改时关闭会二次确认、Ctrl / ⌘ + Enter 快捷保存。返回值中的 openCreate(defaults)、openEdit(record) 打开表单。
抽屉还是子页
| 场景 | 写法 |
|---|---|
| 字段少的简单资料(仓库、库位、职位等) | 默认的抽屉:<FormDrawer :drawer="drawer"> |
| 有明细行、字段多的单据(订单、出入库单、凭证等) | 子页:<FormDrawer page :drawer="drawer" width="min(1200px, 100vw)"> |
子页占满内容区、覆盖在列表之上,返回后列表的筛选、分页、滚动位置不变。单据表单一般封装为组件(如 components/scm/OrderForm.vue),通过 defineExpose({ openCreate, openEdit: drawer.openEdit }) 暴露给列表页,保存成功后 emit('success') 通知列表页刷新;列表页需要据此刷新已打开的详情时带上单据 ID(如 OrderForm、QuotationForm 的 emit('success', id))。
详情:SubPage
单据详情用 SubPage,用法与 a-drawer 一致(open、title、@close,插槽 title、extra、footer;width 等抽屉属性会被忽略):
<template>
<SubPage :open="open" :title="detail ? $t('oa.car.detailTitle', { no: detail.requestNo }) : $t('oa.car.title')" @close="emit('update:open', false)">
<template #extra>
<a-space>
<a-button size="small" @click="print">{{ $t('common.action.print') }}</a-button>
<a-button v-perm="'oa:car:edit'" size="small" @click="emit('edit', detail)">{{ $t('common.action.edit') }}</a-button>
</a-space>
</template>
<a-spin :spinning="loading">
<!-- 详情内容 -->
</a-spin>
</SubPage>
</template>
<script setup>
import { ref, watch } from 'vue'
import SubPage from '@/components/SubPage.vue'
import { getCarRequest } from '@/api/oa/car'
const props = defineProps({ open: Boolean, requestId: Number })
const emit = defineEmits(['update:open', 'edit'])
const detail = ref()
const loading = ref(false)
async function load() {
loading.value = true
try {
detail.value = await getCarRequest(props.requestId)
} finally {
loading.value = false
}
}
watch(() => [props.open, props.requestId], ([open]) => open && load(), { immediate: true })
defineExpose({ reload: load })
</script>从详情进入编辑时不要关闭详情:编辑子页会叠在详情之上,保存后回到详情,由列表页调用详情组件暴露的 reload() 刷新:
const detailRef = ref()
function onSaved(id) {
load() // 刷新列表
if (detailOpen.value && id === detailId.value) detailRef.value?.reload()
}多个子页叠加时后打开的在上面,返回时逐层关闭。
字典:useDict + DictTag
import { useDict } from '@/composables/useDict'
import { toOptions } from '@/utils/dict'
const dict = useDict('sys_common_status', 'wms_warehouse_type')
// 模板中:dict.wms_warehouse_type 为该类型的字典数据数组
// 下拉选项::options="toOptions(dict.wms_warehouse_type)"<!-- 按字典显示带颜色的标签(颜色取字典数据的 tag_color) -->
<DictTag type="wms_warehouse_type" :value="record.type" />字典数据由 store/dict.js 按类型缓存,同一类型只请求一次(接口 /system/dict/data/type/{type},系统字典返回平台的取值,业务字典返回当前租户的取值)。加载后标签按界面语言翻译:英文译文在 locales/en-US/dict.js(字典类型: { 字典值: '英文' }),没有译文时显示数据库中的标签,所以新增内置字典时要同时加英文译文。状态、类型等取值一律通过字典显示,不在页面中硬编码标签文字。
字典分为两种(见 字典),前端的用法相同,区别在于取值能否写在代码里:
| 种类 | 维护 | 前端判断取值时 |
|---|---|---|
| 系统字典(状态、单据类型等) | 平台在「系统管理 → 字典管理」中改名称、颜色、排序,不能增删取值 | 用 @/constants/enums 中的常量(字典值与后端枚举名称一致) |
| 业务字典(客户等级、物料分类、收发类别等) | 租户在「系统管理 → 业务字典」中新增、改名、停用 | 下拉选项全部来自字典,不在页面中写死可选值;只有程序依赖的内置取值可以用字面量或在页面中定义常量 |
不能手工选择的取值
有些字典取值只能由业务单据自动产生(如出入库类型中的「采购入库」由采购入库单生成、项目费用中的「人工」来自工时、商机阶段中的「赢单 / 输单」由专门的操作产生),后端会拒绝手工录入。下拉中仍列出这些取值,但设为不可选并说明来源,让用户知道为什么不能选:
// views/crm/opportunity/index.vue:新建只能选进行中的阶段,赢单 / 输单显示为禁用并提示在商机详情中推进
const openStageOptions = computed(() =>
toOptions(dict.crm_opp_stage).map((o) => (OPEN_STAGES.includes(o.value) ? o : { ...o, label: t('crm.opportunity.closedStageHint', { label: o.label }), disabled: true }))
)说明较长时放在选项右侧(components/wms/wms.js 的 docTypeOptions 返回 { value, label, disabled, hint },StockDocForm.vue 用 #option 插槽显示 hint,并设 option-label-prop="label" 让选中后只显示名称)。新增由业务单据生成的出入库类型时,在 wms.js 的 AUTO_TYPE_SOURCES 中登记来源。
状态与类型常量
页面中比较状态、类型等取值时使用 @/constants/enums 中的常量,不写 'DRAFT' 这样的字面量:
import { OrderStatus, WorkOrderStatus } from '@/constants/enums'
if (record.status === WorkOrderStatus.DRAFT) { ... }
const editable = [OrderStatus.APPROVED, OrderStatus.PARTIAL].includes(d.status)<a-button v-if="record.status === LoanStatus.DRAFT" ...>- 该文件由
npm run gen:enums(scripts/gen-enums.mjs)从后端lemon-common、lemon-framework、lemon-modules中文件名与枚举同名的顶层 Java 枚举生成,不要手工修改;后端新增或修改枚举后重新生成并一起提交。 - 每个枚举导出一个冻结对象,键和值都是常量名(与数据库、JSON 中的取值一致),注释带 Java 类名和对应的字典。
- 导出名为 Java 简单类名;多个模块有同名枚举时全部加模块前缀(如
WmsDocStatus、MesDocStatus),lemon-common/lemon-framework的前缀为Common/Framework。 npm run i18n:check的最后一步(gen-enums.mjs --check)检查生成的文件是否与后端一致,不一致时提示重新生成。- 业务字典(租户可扩展的分类)、Java 类内部的嵌套枚举、前端自己合成的取值不在其中,仍用字面量或在页面 / 模块的 js 中定义常量。
- 用
@JsonValue自定义 JSON 取值的枚举不生成。目前只有启用 / 停用EnableStatus,接口中是数字 1 / 0,前端用@/utils/constants中的STATUS_ENABLED(1)/STATUS_DISABLED(0),如新增表单的默认值status: STATUS_ENABLED。
单条操作:useAction
删除、审核、关闭、提交等单条操作的流程都是「(可选的确认)→ 调接口 → 提示成功 → 刷新」,用 useAction 生成处理函数,不必每次写 async function:
import { useAction } from '@/composables/useAction'
// 参数原样传给接口;a-popconfirm 已经确认过,不再弹确认框
const remove = useAction(deleteLoan, { successKey: 'common.msg.deleteSuccess', reload: () => load() })
// 先弹确认框;成功提示按接口返回值决定
const submit = useAction((r) => submitCarRequest(r.id), {
confirm: (r) => t('oa.car.confirmSubmit', { no: r.requestNo }),
successKey: (approving) => (approving ? t('oa.car.submittedFlow') : t('oa.car.approved')),
reload: () => load()
})<a-popconfirm :title="$t('common.msg.confirmDelete')" @confirm="remove(record.id)">...</a-popconfirm>
<a-button type="link" size="small" @click="submit(record)">{{ $t('common.action.submit') }}</a-button>| 选项 | 说明 |
|---|---|
confirm | 先弹出 Modal.confirm:标题文字、Modal.confirm 的参数对象,或 (...参数) => 前两者(返回空时不确认) |
successKey | 成功提示的消息键(默认 common.msg.operationSuccess),或 (接口结果, ...参数) => 提示文字(返回空不提示);false 不提示 |
reload | (接口结果, ...参数) => 成功后执行,一般是刷新列表或详情。写成 () => load(),即使声明在 useTable 之前也不会出错 |
loading | 一个 ref(false),调用期间为 true,用于按钮的 loading 状态 |
失败时由请求拦截器提示错误,返回的 Promise 会 reject;有确认框时确认框由 Modal 处理,不 reject。
单据明细:useLineItems + LineItemsTable
有明细行的单据表单用 useLineItems 管理增删行、行 key 和合计,表格用 components/common/LineItemsTable.vue:
<div class="items-head">
<span>{{ $t('common.field.total') }} <b>{{ money(itemTotal) }}</b></span>
<a-button size="small" @click="lines.addRow()">{{ $t('common.action.addRow') }}</a-button>
</div>
<LineItemsTable :items="form.items" :columns="itemColumns" :min-rows="1">
<template #cell="{ column, record }">
<template v-if="column.key === 'accountId'">
<AccountSelect v-model:value="record.accountId" />
</template>
<template v-else-if="column.key === 'amount'">
<a-input-number v-model:value="record.amount" :min="0.01" :precision="2" class="full" />
</template>
</template>
</LineItemsTable>import LineItemsTable from '@/components/common/LineItemsTable.vue'
import { useLineItems } from '@/composables/useLineItems'
import { money } from '@/utils/number'
const itemColumns = [
{ title: t('fin.claim.expenseAccount'), key: 'accountId', width: 260 },
{ title: t('common.field.amount'), key: 'amount', width: 140 },
{ title: t('common.action.operation'), key: 'action', width: 60 } // key 为 action 的列默认是删除按钮
]
// 表单对象会被 useFormDrawer 整体替换,所以传函数
const lines = useLineItems(() => form.value.items, { newRow: () => ({ accountId: undefined, amount: undefined }) })
const itemTotal = lines.total('amount') // computed,按 round2 舍入
// 编辑时给后端返回的明细补上 key
fetch: async (record) => {
const d = await getClaim(record.id)
return { ...d, items: d.items.map(lines.withKey) }
}useLineItems(items, { newRow, field }):
| 返回值 | 说明 |
|---|---|
addRow(extra?, { first }?) | 加一行并返回该行,newRow(当前明细) 的默认值被 extra 覆盖;{ first: true } 加在最前面 |
removeRow(行或下标) | 删除一行(表格分页时传行对象) |
withKey(item) | 给后端返回的明细补上 key(已保存的用 item-{id},新增行生成临时 key);也可以单独导入 lineKey、rowKey |
total(字段或函数, round?) | 合计(computed),默认 round2,传 (n) => n 不舍入 |
itemName(record, 字段) | 行内表单项的校验路径 [field, 行位置, 字段],用于 a-form-item 的 name,field 默认 items |
LineItemsTable 的属性和插槽:
| 属性 / 插槽 | 说明 |
|---|---|
items、columns | 必填。行 key 固定为 key;横向滚动宽度默认为各列 width 之和,可用 scroll-x 指定 |
min-rows | 行数小于等于该值时删除按钮不可用(默认 0) |
confirm-remove | (行) => 确认框标题,返回空时直接删除 |
pagination、empty-text | 同 a-table |
#cell | 除操作列外的单元格,参数同 a-table 的 bodyCell(column、record、index) |
#action | 替换默认的删除按钮 |
@remove | 删除一行后触发,参数为该行 |
完整的单据示例见 单据开发公共组件,现有页面可参考 components/scm/QuotationForm.vue、components/wms/CheckForm.vue、views/fin/claim/index.vue。
数字的显示与舍入
金额、数量的显示和舍入统一用 utils/number.js,与后端 Decimals 的精度一致(金额 2 位、数量和单价 4 位,四舍五入),不要在页面里再写 toLocaleString、Math.round(x * 100) / 100:
| 函数 | 说明 |
|---|---|
money(n, empty = '-') | 千分位、2 位小数,空值显示 empty |
moneyOrBlank(n) | 同上,0 或空值显示为空(账簿中更易读) |
price(n) | 千分位、2~4 位小数 |
qty(n, digits = 4) | 千分位,去掉多余的 0,最多 4 位小数,空值显示 - |
num(v, digits = 0) | 千分位,最多 digits 位小数,空值或非数字显示 - |
formatDecimal(n, { min, max }) | 自定义小数位数 |
round2(n) / round4(n) | 四舍五入到 2 位 / 4 位,提交前处理浮点误差(如 0.3 - 0.1) |
nz(n) | 空值、非数字按 0 处理 |
sumBy(list, 字段或函数, round = round2) | 求和 |
模块中已有的 formatMoney(components/scm/scm.js)、formatQty(components/wms/wms.js)等是这些函数的封装,可以继续使用。
下拉框与表格的显示
全局约定(main.js、style.css、utils/ellipsisTitle.js),页面不需要额外处理:
- 下拉框按内容宽度展开:
Select、TreeSelect的dropdownMatchSelectWidth默认改为false,弹层不窄于输入框、最宽 560px,更长的选项换行显示而不是省略,所以放在表格窄列中的下拉框也能看全选项。false会关闭虚拟滚动,选项可能很多的下拉保留虚拟滚动,给一个固定宽度的数字:物料、科目400,客户 / 供应商、项目360,员工、用户300,仓库、库位280(见MaterialSelect、AccountSelect、PartnerSelect等选择组件)。新写这类远程搜索的选择组件时同样设置:dropdown-match-select-width="数字"。 - 编辑表格中的下拉框占满单元格:表格单元格内的
a-select默认width: 100%(:where()选择器,不增加优先级,组件中显式设置的宽度仍然生效)。可编辑明细表中放物料、库位、税率等下拉的列要留够宽度(物料约 290、库位约 200)。 - 省略单元格悬停显示全文:列设置了
ellipsis: true时,antd 只给纯文本单元格加title;installEllipsisTitle全局监听鼠标悬停,用#bodyCell插槽渲染的单元格内容被截断时也会显示完整文字。 - 表格中的标签换行:非省略列中的
a-tag(如DictTag)在窄列中换行显示,不会溢出单元格,英文界面的长标签也能完整显示。 - 子页标题:
SubPage的标题过长被截断时悬停显示全文。
拆分大页面
页面超过约 500 行时,把弹窗、标签页、表单拆成同目录下的子组件,index.vue 只保留列表和组件的组装:
views/mes/wo/
├── index.vue 列表、查询、打开子组件
├── wo.js 本页面共用的常量和函数
├── WoForm.vue 新增 / 编辑
├── WoDetail.vue 详情(SubPage),内含 WoMaterialsTab、WoOpsTab、WoReportsTab、WoDocsTab
└── WoReportModal.vue、WoQtyModal.vue、WoSplitModal.vue、WoReworkModal.vue 各种操作弹窗子组件通过 defineExpose({ open }) 暴露打开方法,操作完成后 emit('success') 等事件通知页面刷新。被多个页面使用的表单、详情放到 components/<模块>/ 下。
按钮权限:v-perm
<a-button v-perm="'wms:warehouse:add'" type="primary">{{ $t('wms.warehouse.createTitle') }}</a-button>
<!-- 数组:满足其一即可 -->
<a-button v-perm="['scm:purchase:add', 'scm:purchase:edit']">{{ $t('common.action.save') }}</a-button>
<!-- a-popconfirm 这类没有根元素的组件,外面包一层元素 -->
<span v-perm="'wms:warehouse:remove'">
<a-popconfirm :title="$t('wms.warehouse.confirmDelete', { name: record.name })" @confirm="remove(record.id)">
<a-button type="link" danger size="small">{{ $t('common.action.delete') }}</a-button>
</a-popconfirm>
</span>v-perm 在挂载时移除没有权限的元素。需要在脚本中判断时用 useUserStore().hasPerm(perm),例如 StatusSwitch 的 :disabled="!userStore.hasPerm('wms:warehouse:edit')"。
样式与深色模式
- 自定义样式使用全局 CSS 变量(定义在
src/style.css):--app-primary、--app-bg、--app-card、--app-border、--app-hover、--app-text、--app-text-2、--app-text-3等。亮色为默认值,html[data-theme='dark']时切换为暗色,使用变量的样式自动适配深色模式。 - 页面私有样式写在
<style scoped>中。 - 必须针对深色模式单独写的样式(变量覆盖不到的渐变、图片透明度等),写在非 scoped 的
<style>中,并用页面根类名限定范围,因为需要匹配根元素html上的data-theme,而 scoped 样式中的:global()不生效:
<style scoped>
.hero {
background: var(--app-card);
}
</style>
<style>
/* 暗色:需要匹配根元素的 data-theme,不能放在 scoped 中 */
html[data-theme='dark'] .workbench .hero {
background: linear-gradient(100deg, rgba(255, 212, 59, 0.07), rgba(0, 0, 0, 0) 55%), var(--app-card);
}
</style>移动端
- 页面要在窄屏下可用:表单用
a-row/a-col的响应式栅格(:xs="24" :sm="12"),抽屉宽度用min(640px, 100vw)。 - 手机 / PDA 的移动作业页面放在
views/mobile下,菜单路径以/m开头,使用全屏的MobileLayout。 - 微信小程序是单独的项目
lemon-mp(uni-app),与 Web 端共用后端接口和constants/enums.js、locales/en-US/dict.js。修改这两个文件中已有的常量名或字典键时,同时检查小程序中的引用(@web/...);首页待办、站内消息的链接在小程序中按 Web 端路由前缀转到对应页面,修改 Web 端路由时同步修改lemon-mp/src/config/entries中的links。见 微信小程序开发。
数据大屏
数据大屏不使用后台布局,是两个静态路由(router/index.js):
| 路由 | 页面 | 说明 |
|---|---|---|
/screen/:key | views/screen/ScreenPage.vue | 登录后查看,key 为 factory(生产车间)/ progress(生产进度)/ warehouse / sales / quality(不区分大小写);?rotate=factory,quality&interval=60 轮播,?workCenterId= 生产车间、生产进度按工作中心显示(screen.js 的 WORK_CENTER_SCREENS) |
/screen-share/:token | views/screen/ShareScreenPage.vue | 投屏链接,免登录。路由守卫对 SCREEN_SHARE_PATH 开头的路径直接放行,不加载用户和菜单(电视上可能残留过期的登录令牌) |
两者都交给 ScreenRunner.vue:轮播、每 30 秒刷新当前大屏、请求失败时保留上次数据并在标题栏提示离线。通用组件在 components/screen:
| 组件 | 作用 |
|---|---|
ScreenShell | 外壳:按 1920 × 1080 设计、transform: scale 等比缩放铺满窗口;标题栏(企业名称、日期、时钟、更新时间 / 离线提示)、多屏切换、右下角轮播 / 全屏 / 返回按钮 |
ScreenKpi | 指标卡(数字滚动动画),属性 label、value、unit、digits、sub、subClass(up / down)、color |
ScreenPanel | 面板(标题 + #extra 插槽) |
ScrollList | 自动滚动列表,columns 的 width 用 CSS Grid 写法(140px、1fr),#cell 插槽自定义单元格 |
screen.js | SCREENS 大屏清单、WORK_CENTER_SCREENS 可按工作中心显示的大屏、配色 COLORS / PALETTE、fmtNum / fmtMoney / fmtRate 格式化、axisStyle / tooltip / legend / gradient 图表样式 |
图表使用 <EChart :option height="100%" dark />,dark 强制暗色配色,不跟随系统主题。大屏的数据请求在 api/bi/screen.js 中用独立的 axios 实例:失败时不弹全局提示、401 不跳转登录页(无人值守的电视不能被弹窗打断),投屏链接请求不带登录令牌。
新增一块大屏
- 后端:在
bi/model/ScreenKey中加枚举值(参数为大屏标题的消息键和能否按工作中心显示,如FACTORY("bi.label.factory", true),消息键在bi.properties、bi_en.properties中登记),在bi/dto/BiScreen中加一个实现ScreenData的 record(带key、title、time,ScreenData是sealed接口,要在permits中加上),在BiScreenService.data的switch中加一个方法,一次返回kpi、各图表和列表的全部数据(统计查询的结果用带 setter 的类,不返回Map);统计 SQL 写在BiScreenMapper.xml中,按租户自动过滤,不加数据权限(大屏接口要求「全部数据」)。列表加LIMIT,趋势类数据用fillDays补齐没有数据的日期。 - 前端:在
views/screen下新建XxxScreen.vue(接收data属性,引入./screen.css使用screen-grid的「一行指标卡 + 两行面板」布局),在ScreenRunner.vue的COMPONENTS中登记,在components/screen/screen.js的SCREENS中加入key、label、desc(用t('screen.screens.xxx'),译文在locales/<语言>/screen.js;数据大屏页面的卡片、投屏链接表单的选项都来自这里;能按工作中心显示的同时加入WORK_CENTER_SCREENS)。大屏上直接拿到的字典值用dictLabel/withDictLabel按语言显示。 - 投屏链接按
ScreenKey保存(bi_screen_share.screens逗号分隔),无需改表;如果screens的总长度可能超过 128 个字符,需要加宽该列。 - 只适合部分用户看的数据(如薪资)不要放进大屏:大屏可以通过投屏链接免登录查看。
国际化
- 不要在页面中硬编码中文:模板用
$t('键'),<script setup>中const { t } = useI18n(),普通 js 模块import { t } from '@/locales';键同时加到locales/zh-CN/<模块>.js和locales/en-US/<模块>.js。 - 通用的操作、字段、状态、提示、校验优先复用
common.*(common.action.save、common.field.status、common.msg.saveSuccess、common.rule.required等)。 - 带参数的句子写成一个完整的键(
'确定删除仓库「{name}」?'),不要用t()拼接片段;文字中的@ | $ { }写成{'@'}。 - 新增内置菜单时在
locales/en-US/menu.js按菜单 ID 加英文名称,新增内置字典时在dict.js加译文。 - 只对中文有意义的处理(金额大写等)用
isZh()判断;有意保留的中文在行内写i18n-ignore。 - 提交前运行
npm run i18n:check,并切换为 English 检查布局(英文通常更长)。详见 国际化。
其他约定
- 不使用
alert、confirm,确认用Modal.confirm或a-popconfirm,提示用message。 - 复用已有的格式化函数保持全站一致:金额、数量、单价用
utils/number.js(见 数字的显示与舍入),金额大写components/fin/fin.js的moneyUpper,文件大小、时长等用utils/format.js。 - 打印使用
utils/print.js的printDocs(后端生成 PDF,在全局的打印预览窗口中打印或下载),见 单据编号与打印。 - 需要支持自定义字段的表单、列表、详情使用
useCustomFields、CustomFieldsForm、useCustomColumns、CustomFieldsView,见 业务实体接入自定义字段。 - 前端没有配置 lint 和测试,提交前请在浏览器中验证亮色、深色两种主题、中英文两种语言和窄屏显示。