This commit is contained in:
oneao committed 2026-10-13 17:29:55 +08:00
1 parent 7bd5f1dde9
commit f3936cec61
70 files changed
+13074 -187

No files matched your search

+13
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterView } from 'vue-router'
import { G3FpsMonitor } from '@g3soft/ui'
import ErrorBoundary from '@/views/fallback/ErrorBoundary.vue'
import FallbackPage from '@/views/fallback/FallbackPage.vue'
import { useOnline } from '@/composables/useOnline'
import { usePreferencesStore } from '@/preferences/store'
/**
* 应用根:渲染当前路由页面 + 离线兜底层。
@@ -18,6 +21,14 @@ import { useOnline } from '@/composables/useOnline'
const { isOffline } = useOnline()
/**
* FPS 性能监控浮层:由偏好 app.enableFpsMonitor 控制显隐。
* 参考 fms-vue 的 App.vue(那里全环境常驻),这里改成按开关挂载,
* 默认关闭,避免生产环境默认顶着一个监控浮层。
*/
const preferencesStore = usePreferencesStore()
const showFpsMonitor = computed(() => preferencesStore.preferences.app.enableFpsMonitor)
/**
* 离线兜底:断网时在最上层【盖一页】离线缺省页,恢复后自动移除。
*
@@ -36,6 +47,8 @@ const { isOffline } = useOnline()
<RouterView />
</ErrorBoundary>
<G3FpsMonitor v-if="showFpsMonitor" />
<FallbackPage v-if="isOffline" status="offline" class="app-offline" />
</template>
@@ -706,6 +706,13 @@ const hasDiff = computed(() => true)
>
动态标题
</SwitchItem>
<SwitchItem
:model-value="pref.app.enableFpsMonitor"
tip="在页面右上角显示 FPS 性能监控浮层,用于现场排查滚动 / 动画卡顿"
@update:model-value="(v) => store.update({ app: { enableFpsMonitor: v } })"
>
FPS 监控
</SwitchItem>
</PrefBlock>
<PrefBlock title="动画">
@@ -18,6 +18,7 @@ export const defaultPreferences: Preferences = {
contentPadding: 16,
dynamicTitle: true,
enablePreferences: true,
enableFpsMonitor: false,
zIndex: 200,
},
@@ -104,6 +104,8 @@ export interface AppPreferences {
dynamicTitle: boolean
/** 是否显示偏好设置入口 */
enablePreferences: boolean
/** 是否显示 FPS 性能监控浮层 */
enableFpsMonitor: boolean
/** z-index 基准 */
zIndex: number
}
+284 -87
View File
@@ -1,23 +1,24 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { G3Message, G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
import type { G3TableColumn, TableExpose, TableRow } from '@g3soft/ui'
/**
* 订舱单(海运)—— G3Table 使用范例。
* 订舱单(海运)—— G3Table 行内编辑的**完整用法范例**。
*
* 三个要点:
* 1. 假数据:本地生成,不请求接口
* 2. 条数可调:默认 1000 行,用 rowCount 决定数据量
* 3. 行编辑:列上配 editor,单击进入;Enter 全选内容(便于覆写)、
* Esc 取消并恢复原值、失焦提交;值变化经 change 事件回写到本地数据
* 覆盖的能力(按「配置一处、演示一处」组织,方便照抄):
* 1. 编辑器类型:text / number / select / checkbox
* 2. 校验态:error(红底 + ! 角标)与 warning(琥珀底 + ! 角标)
* 3. 禁用态:editor.disabled(整列禁用 / 按行条件禁用)
* 4. 只读列:没配 editor 的列在编辑期自动灰底
* 5. 当前行高亮:受控 activeRowKey + @row-click(与 fms-vue 的 FmsTable 同款写法)
* 6. 提交流程:保存时整表校验,未通过则定位到首个错误格
*
* 不分页:1000 行直接交给表格,由 G3Table 内部的**虚拟滚动门控**兜住性能
* (> 100 行才开启,DOM 里只渲染视口内若干行)。
* **本页始终处于可编辑状态**(没有「编辑明细」开关按钮):
* 进去就能直接点任意单元格改,`editable` 用组件默认值 true。
* 没有任何行被默认激活 —— 编辑目标是用户点出来的。
*
* 需要留意 G3Table 的两条行为(文档「实现说明」里有写):
* - rowKey 默认取 'id',数据里必须存在该字段且唯一,否则编辑态会串行
* - 列宽/列序拖拽要求 v-model:columns 绑定;本例不需要,故不绑(组件会自动关掉)
* 数据为本地假数据,不请求接口。
*/
defineOptions({ name: 'OceanBooking' })
@@ -32,9 +33,13 @@ interface BookingRow extends TableRow {
etd: string
containerCount: number
status: number
enabled: number
remark: string
/** 关务已放行:这类行整行只读,用来演示「按行条件禁用」 */
customsCleared: number
}
/* ---------------- 假数据 ---------------- */
/* ---------------- 选项 ---------------- */
const PORT_OPTIONS = [
{ label: '上海', value: 'CNSHA' },
@@ -53,10 +58,9 @@ const STATUS_OPTIONS = [
]
const CUSTOMERS = ['中远海运', '东方海外', '马士基', '长荣海运', '赫伯罗特', '达飞轮船']
const VESSELS = ['EVER GIVEN', 'MSC OSCAR', 'COSCO SHIPPING', 'ONE APOLLO', 'HMM ALGECIRAS']
/** 简易可复现伪随机:同一个 index 永远得到同一行,翻页时数据不跳变。 */
/** 简易可复现伪随机:同一个 index 永远得到同一行,改数据量时数据不跳变。 */
function seeded(n: number): number {
const x = Math.sin(n * 12.9898) * 43758.5453
return x - Math.floor(x)
@@ -66,24 +70,45 @@ function pick<T>(list: T[], n: number): T {
return list[Math.floor(seeded(n) * list.length) % list.length]
}
/** 生成第 index 条假订舱单 */
/**
* 生成第 index 条假订舱单。
*
* 刻意造出几种**会触发校验**的数据,让错误/警告态一进编辑就能看到,
* 不需要用户先手动改坏数据:
* - 每第 4 行 箱量 为 0 → 触发 error(必须大于 0)
* - 每第 5 行 客户 为空 → 触发 error(必填)
* - 每第 3 行 箱量为个位数且未确认 → 触发 warning(箱量偏少,疑似录入错误)
* - 每第 7 行 customsCleared = 1 → 整行只读(演示按行禁用)
*/
function makeRow(index: number): BookingRow {
const m = String((index % 12) + 1).padStart(2, '0')
const d = String(((index * 3) % 27) + 1).padStart(2, '0')
const status = Math.floor(seeded(index * 7.3) * 4)
const isQtyEven = index % 4 === 1
const isCustomerEmpty = index % 5 === 2
const isQtySmall = index % 3 === 0
return {
id: index + 1,
bookingNo: `BK${String(20260000 + index + 1)}`,
customer: pick(CUSTOMERS, index * 1.7),
customer: isCustomerEmpty ? '' : pick(CUSTOMERS, index * 1.7),
pol: pick(PORT_OPTIONS, index * 2.3).value,
pod: pick(PORT_OPTIONS, index * 3.1).value,
vessel: pick(VESSELS, index * 4.7),
etd: `2026-${m}-${d}`,
containerCount: Math.floor(seeded(index * 5.9) * 90) + 10,
status: Math.floor(seeded(index * 7.3) * 4),
containerCount: isQtyEven
? 0
: isQtySmall
? Math.floor(seeded(index * 5.9) * 5) + 2
: Math.floor(seeded(index * 5.9) * 90) + 10,
status,
enabled: index % 3 === 0 ? 0 : 1,
remark: index % 6 === 0 ? '' : pick(['已归档', '待复核', '加急', '客户指定船期'], index * 2.1),
customsCleared: index % 7 === 3 ? 1 : 0,
}
}
/* ---------------- 数据量 ---------------- */
/* ---------------- 数据 ---------------- */
/** 数据条数:改这个值就等于「指定数据有多少」 */
const rowCount = ref(1000)
@@ -91,110 +116,256 @@ const rowCount = ref(1000)
/**
* 假数据。
*
* 不做分页:1000 行**一次性**交给表格。G3Table 内部按行数门控虚拟滚动
* (> 100 行才开启),所以这里会走虚拟滚动路径,DOM 里只渲染视口内的行,
* 1000 行与 10 行的滚动开销几乎无差别。
* 不做分页:一次性交给表格,由 G3Table 内部的**虚拟滚动门控**兜住性能
* (> 100 行才开启,DOM 里只渲染视口内若干行)。
*
* ⚠ 必须用 `ref`(深层响应式),**不能用 `computed` 返回普通对象数组**。
*
* 行内编辑是靠「直接改 row 对象上的字段」回写的(见 utils/editable.ts 的
* onUpdate:modelValue → row[dataIndex] = value)。若行对象不是响应式代理,
* 这次写入**不会触发任何重渲染** —— 表现极具迷惑性:
* · change 事件照常抛出(数据确实改了)
* · 但界面上仍旧显示旧值
* · 点一下别处(触发无关重渲染)才"突然"更新
* 实测:computed 数组里 row.pol 从 CNSHA 改成 CNNGB 后,
* G3Select 收到的 modelValue 仍是 CNSHA,界面纹丝不动。
*
* ref 会递归代理内部对象,行字段的读写都可被追踪,编辑器选完即刷新。
* 行内编辑是靠「直接改 row 对象上的字段」回写的,若行对象不是响应式代理,
* 这次写入不会触发任何重渲染。症状极具迷惑性:change 照常抛出(数据确实改了),
* 但界面仍显示旧值,点一下别处才「突然」更新。
*/
const rows = ref<BookingRow[]>([])
watch(
rowCount,
(n) => {
rows.value = Array.from({ length: n }, (_, i) => makeRow(i))
},
{ immediate: true },
)
function rebuild() {
rows.value = Array.from({ length: rowCount.value }, (_, i) => makeRow(i))
}
watch(rowCount, rebuild, { immediate: true })
/* ---------------- 校验规则 ---------------- */
/**
* 校验规则集中在这里,便于复用与单测。
*
* 语义(务必记牢):**返回 falsy 表示通过;返回非空 string 表示不通过**,
* 且该字符串就是错误原因。所以 `Number(v) > 0 || '必须大于 0'` 这种写法是对的,
* 而 `(v) => Number(v) > 0`(只返回布尔)**永远不会报错** ——
* 布尔 true 被当作通过、false 也被当作通过。要参与判错必须返回字符串。
*/
const validateQty = (value: unknown) => {
const n = Number(value)
if (!Number.isFinite(n)) return '箱量必须是数字'
if (n <= 0) return '箱量必须大于 0'
return ''
}
const validateCustomer = (value: unknown) =>
String(value ?? '').trim().length > 0 || '客户名称不能为空'
/**
* 警告级规则:与 `validate` 的调用约定完全相同(falsy 通过 / string 表示有问题),
* 只是渲染成 warning(琥珀底 + ! 角标)而不是 error(红底)。
*
* 为什么用两个字段而不是让 validate 返回 {level, message}:
* 这两条是**独立的规则**,不是同一条规则的两个等级。同一格可能同时命中
* 「必填(error)」与「箱量偏少(warning)」,谁更严重由组件统一裁决
* (error 优先),业务不必自己写 if/else 比较等级。
*/
const warnQtySmall = (value: unknown) => {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return ''
return n < 5 ? `箱量仅 ${n},远低于常规值,请确认是否录入错误` : ''
}
/* ---------------- 列定义 ---------------- */
/**
* 列定义用 `G3TableColumn<TableRow>`(而非 `G3TableColumn<BookingRow>`)。
*
* 原因:G3Table 的 props 是按 `TableRow` 声明的(`columns?: G3TableColumn<TableRow>[]`),
* 没有暴露泛型参数。函数类型参数是**逆变**的,所以带 `sorter` / 带
* `editor.options((row: BookingRow) => ...)` 的列无法赋给它 —— 直接写
* `G3TableColumn<BookingRow>[]` 会报 TS2322。
*
* 行数据的类型安全改由函数体内断言承担:columns / 事件入参都必须是
* `TableRow`(组件的 props 与 emit 都按它声明),需要具体字段时在回调里
* `row as BookingRow`(见下方 onChange)。
* 原因:G3Table 的 props 是按 `TableRow` 声明的,没有暴露泛型参数,
* 而函数类型参数是**逆变**的,带具体行类型的 `editor.options((row: BookingRow) => ...)`
* 无法赋给它(会报 TS2322)。行数据的类型安全改由函数体内断言承担。
*/
const columns = ref<G3TableColumn<TableRow>[]>([
{ dataIndex: 'bookingNo', title: '订舱号', width: 130 },
{
dataIndex: 'bookingNo',
title: '订舱号',
width: 130,
// 没配 editor:编辑期该列自动灰底,一眼看出不可改
},
{
dataIndex: 'customer',
title: '客户',
width: 140,
// 文本编辑器:单击进入,失焦提交,Esc 取消并恢复原值
editor: { type: 'text' },
title: '客户(必填)',
width: 150,
editor: {
type: 'text',
validate: validateCustomer,
},
},
{
dataIndex: 'pol',
title: '起运港',
width: 120,
// select 编辑器:options 传数组;非编辑态会把 value 解析成 label 展示
width: 110,
editor: { type: 'select', options: PORT_OPTIONS },
},
{
dataIndex: 'pod',
title: '目的港',
width: 120,
editor: { type: 'select', options: PORT_OPTIONS },
width: 110,
editor: {
type: 'select',
options: PORT_OPTIONS,
// 目的港不能与起运港相同——演示「校验依赖同行其它列」,
// validate 的第 2 个参数就是当前行
validate: (value: unknown, row: TableRow) =>
String(value) !== String((row as BookingRow).pol) || '目的港不能与起运港相同',
},
},
{
dataIndex: 'vessel',
title: '船名',
width: 160,
editor: { type: 'text' },
},
{
dataIndex: 'etd',
title: '开航日',
width: 110,
editor: { type: 'text' },
},
{ dataIndex: 'vessel', title: '船名', width: 170, editor: { type: 'text' } },
{ dataIndex: 'etd', title: '开航日', width: 120, editor: { type: 'text' } },
{
dataIndex: 'containerCount',
title: '箱量',
width: 100,
width: 90,
align: 'right',
// 数字编辑器
editor: { type: 'number' },
editor: {
type: 'number',
// error 与 warning 同时配置:值为 0 → error(红底);
// 值为 1~4 → warning(琥珀底)。两者互斥,error 优先。
validate: validateQty,
validateWarning: warnQtySmall,
},
},
{
dataIndex: 'enabled',
title: '启用',
width: 70,
// checkbox 编辑器:始终可点,不需要先激活整行编辑
editor: { type: 'checkbox' },
},
{
dataIndex: 'status',
title: '状态',
width: 120,
editor: { type: 'select', options: STATUS_OPTIONS },
width: 110,
editor: {
type: 'select',
options: STATUS_OPTIONS,
// 按行条件禁用:已放舱(2)之后不允许再改状态
disabled: (row: TableRow) => (row as BookingRow).status === 2,
},
},
{
dataIndex: 'remark',
title: '备注',
width: 130,
editor: { type: 'text' },
},
])
/* ---------------- 编辑回写 ---------------- */
/* ---------------- 编辑与提交 ---------------- */
const lastChange = ref('')
/** 当前正在编辑的行 key —— 与 fms-vue 的 FmsTable 同款受控写法 */
const activeKey = ref<string | null>(null)
/**
* 行编辑后把值写回本地数据。
* 本页**始终可编辑**(没有「编辑明细」开关):
* 组件库的 `editable` prop 默认就是 true,所以模板里不传它。
* 没有任何行被默认激活;用户点哪一格就编辑哪一格。
*
* G3Table 在编辑时会**直接改行对象**(编辑器组件内部已写入),这里主要是:
* 1. 拿到「哪一行、哪一列、改成了什么」,对接后端时在这里发接口;
* 2. 只有值**真的变化**才会触发 —— 组件按编辑开始时的快照做了去重,
* 「点进去又点出来」不会产生无意义的 change。
*
* ⚠ 入参类型必须是 `TableRow`,不能写 `BookingRow`:
* 组件的 emit 签名是 `(e: 'change', row: TableRow, col: G3TableColumn<TableRow>, ...)`,
* 事件处理器的参数类型直接来自它,写窄了会报 TS2322(与上面 columns 同因)。
* 需要具体字段时在函数体内断言,见下。
* 注意「开放编辑能力」与「某行处于编辑态」是两件事:
* text / number 编辑器只在行被激活后才挂载,
* 而 checkbox / select 始终挂载(详见组件里 resolveCustomCell 的门控)。
*/
function onChange(row: TableRow, col: G3TableColumn<TableRow>, value: unknown) {
const booking = row as BookingRow
lastChange.value = `${booking.bookingNo} 的「${col.title}」改为 ${value}`
}
/**
* 用当前列定义把整表校验一遍,返回首个出错的单元格。
*
* 这里**重新跑一遍 validate**(而不是复用编辑器内部的状态)是刻意的:
* 校验规则是纯函数、结果是行数据的**派生值**,重算没有副作用也不会不同步。
* 组件内部那个「红底角标」只是为了即时反馈,真正的提交把关放在业务侧。
*/
function findFirstError(): { rowIndex: number; colIndex: number; message: string } | null {
for (let r = 0; r < rows.value.length; r += 1) {
const row = rows.value[r]
for (let c = 0; c < columns.value.length; c += 1) {
const col = columns.value[c]
const editor = col.editor
if (!editor?.validate) continue
if (typeof editor.disabled === 'function' ? editor.disabled(row) : editor.disabled) continue
const result = editor.validate(row[col.dataIndex], row, col)
if (typeof result === 'string' && result.length > 0) {
return { rowIndex: r, colIndex: c, message: result }
}
}
}
return null
}
/**
* 表格实例引用,用于「保存失败时定位到首个错误格」。
*
* 类型直接用组件库导出的 `TableExpose`,而不是自己就地写一个
* `{ editRow?: ... }`:后者把方法声明成**可选**,于是调用处要么报
* TS2722(possibly undefined),要么被迫用 `?.()` 把真正的拼写错误也一起吞掉。
* 用官方类型还能在组件库改签名时第一时间发现不兼容。
*/
const tableRef = ref<TableExpose | null>(null)
/**
* ⚠️ 这里**刻意不默认激活任何一行**。
*
* 曾经在 onMounted 里调 `editRow(第一行)`,让页面一进来就有编辑器。
* 但那样会带来两个反效果:
* 1. 一进页面就有一格处于**编辑中**,用户还没操作就被"占了"一格,
* 视觉上也不知道该不该继续;且高亮/灰底都指着第一行,像是有默认选中。
* 2. 更重要:它掩盖了「只有被点击过的行才挂载编辑器」这一事实,
* 让人误以为整表都是编辑态。
*
* 现在改为**由用户点击哪一格就编辑哪一格**。校验态的全表可见性由
* 组件侧的 `validateAll`(默认开启)保证,不再依赖「先激活一行」。
*/
// 数据量变化会重建 rows,之前的编辑目标已失效,清掉高亮即可
watch(rowCount, () => {
activeKey.value = null
})
function saveEdit() {
const first = findFirstError()
if (first) {
const row = rows.value[first.rowIndex]
const col = columns.value[first.colIndex]
// 定位到首个错误行:activeKey 管高亮,editRow 管进入编辑
activeKey.value = String(row.id)
tableRef.value?.editRow(row.id, col.dataIndex)
G3Message.error(`第 ${first.rowIndex + 1} 行「${col.title}」校验未通过:${first.message}`)
return
}
G3Message.success('校验通过,明细已保存(本地演示,未请求接口)')
}
/** 出错单元格数量:给工具栏显示一个汇总,也顺带演示「校验是纯派生值」 */
const errorCount = computed(() => {
let n = 0
for (const row of rows.value) {
for (const col of columns.value) {
const editor = col.editor
if (!editor?.validate) continue
const result = editor.validate(row[col.dataIndex], row, col)
if (typeof result === 'string' && result.length > 0) n += 1
}
}
return n
})
function onRefresh() {
rebuild()
activeKey.value = null
G3Message.success(`已刷新(共 ${rowCount.value} 条)`)
}
</script>
@@ -207,30 +378,37 @@ function onRefresh() {
<label class="booking__field">
数据量
<select v-model.number="rowCount" class="booking__select">
<option v-for="n in [10, 100, 1000, 5000, 20000]" :key="n" :value="n">
{{ n }} 条
</option>
<option v-for="n in [10, 100, 1000, 5000, 20000]" :key="n" :value="n">{{ n }} 条</option>
</select>
</label>
<button class="booking__btn" type="button" @click="onRefresh">刷新</button>
<button class="booking__btn" type="button" @click="saveEdit">保存明细</button>
<button class="booking__btn booking__btn--ghost" type="button" @click="onRefresh">
刷新
</button>
<span v-if="errorCount" class="booking__badge">{{ errorCount }} 处待修正</span>
</div>
<p class="booking__tip">
单击单元格进入编辑 · 失焦(点别处)提交 · Enter 全选内容 · Esc 取消并恢复原值 ——
单击单元格直接编辑 · 失焦提交 · Enter 全选内容 · Esc 取消并恢复原值 ——
<strong>灰底列不可改</strong>,必填 / 箱量 / 目的港已预置若干校验项
<template v-if="lastChange">
最近一次修改:<strong>{{ lastChange }}</strong>
| 最近一次修改:<strong>{{ lastChange }}</strong>
</template>
<template v-else>(还没改过)</template>
</p>
<!-- 表格区域:G3Table 需要父级有确定高度,这里用 flex:1 撑开 -->
<div class="booking__table">
<G3Table
:columns="columns"
ref="tableRef"
v-model:columns="columns"
:data-source="rows"
row-key="id"
:row-height="32"
:row-height="28"
:active-row-key="activeKey"
@row-click="(_, row) => (activeKey = String(row.id))"
@change="onChange"
/>
</div>
@@ -252,7 +430,7 @@ function onRefresh() {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
gap: 12px;
flex: none;
}
@@ -300,6 +478,25 @@ function onRefresh() {
&:hover {
opacity: 0.88;
}
&--ghost {
color: var(--g3-color-text, #1f2329);
background: transparent;
border: 1px solid var(--g3-color-border, #e5e6eb);
&:hover {
background: rgba(0, 0, 0, 0.03);
opacity: 1;
}
}
}
&__badge {
padding: 3px 8px;
font-size: 12px;
color: var(--g3-color-danger, #ec4246);
background: rgba(236, 66, 70, 0.1);
border-radius: 999px;
}
&__tip {