This commit is contained in:
oneao committed 2026-10-12 22:25:41 +08:00
1 parent ae3ca3cfa1
commit 7bd5f1dde9
45 files changed
+3414 -229

No files matched your search

@@ -221,10 +221,21 @@ watch(
--sidebar-muted-color: var(--g3-color-text-tertiary, #86909c);
--sidebar-title-color: var(--g3-color-foreground, #1f2329);
/* 全局深色(html.dark):整片侧栏切深色。
与 LayoutSidebar 的处理保持一致;「深色侧栏」开关由各组件自己的
.is-dark 覆盖(见 LayoutMixedRail / LayoutMixedColumn)。 */
.dark & {
/* ---- 深色侧栏:两个入口都要接上 ----
* html.dark 全局深色模式
* html.semi-dark-sidebar 「深色侧栏」开关(浅色模式下由偏好开启,
* 见 preferences/update-css-variables.ts:103)
* 二者的并集恰好等于「侧栏该是深色」(semiDarkSidebar || isDark),
* 也就是双列两列各自 is-dark 的判断条件(LayoutMixedRail:32 / LayoutMixedColumn:25)。
*
* ⚠️ 少接一根就是这个 bug:半深色侧栏**不会**给 html 加 .dark,所以只写 `.dark &`
* 时,双列两列的 `.is-dark` 只把 background 刷成 #1d2129,而 --sidebar-item-color
* 仍停在上面那行的深灰 #1f2329 —— 深字压深底,菜单文字看不见(实测对比度 1.89,
* WCAG 正文下限是 4.5)。
* 单列布局没这个问题:LayoutSidebar 自带同名变量组,会覆盖这里的值。
* 深色档取值与 LayoutSidebar 的 `.is-dark` 保持一致,两种布局同色。 */
.dark &,
.semi-dark-sidebar & {
--sidebar-bg: #1d2129;
--sidebar-border: #2e3238;
--sidebar-item-color: rgb(255 255 255 / 80%);
@@ -110,12 +110,16 @@ const showLogoText = computed(
background: var(--sidebar-bg, #fff);
border-right: 1px solid var(--sidebar-border, #e5e6eb);
/* 深色侧栏(semiDarkSidebar):与 LayoutSidebar 的 .is-dark 同一组值。
全局深色(html.dark)由 DefaultLayout 根上的 .dark 规则统一处理。 */
&.is-dark {
background: #1d2129;
border-right-color: #2e3238;
}
/* 深色侧栏:这里**不写任何颜色**。
底色与前景(文字 / hover / 选中 / 箭头 / 标题)全部由 DefaultLayout 的
.layout 调色板按 .dark / .semi-dark-sidebar 整体切换 —— 本列是 .layout 的
子节点,直接继承那组 --sidebar-*。
早期这里只刷了 background / border,没管前景,于是「深色侧栏 + 双列菜单」下
菜单文字仍是浅色档的深灰 #1f2329,压在 #1d2129 上(实测对比度 1.89,看不见)。
⚠️ is-dark 这个类本身**要保留**(模板上仍在):styles/skin-shadcn.scss:123
用 `html[data-g3-style='shadcn'] .layout-mixed-column.is-dark` 提权覆盖色板,
删掉类会让 shadcn 皮肤的深色侧栏静默失效。 */
/* logo 文字行(对照 vben:VbenLogo 放进 #extra-title)。
高度由模板行内 style 给(顶栏高 - 1,与 vben 的 extraTitleStyle 一致)。
@@ -176,11 +176,15 @@ defineExpose({ railItems, activeRootKey, isLeafOnly })
white-space: nowrap;
}
/* 深色侧栏(semiDarkSidebar):与 LayoutSidebar 的 .is-dark 同一组值。
全局深色(html.dark)由 DefaultLayout 根上的 .dark 规则统一处理。 */
&.is-dark {
background: #1d2129;
border-right-color: #2e3238;
}
/* 深色侧栏:这里**不写任何颜色**。
底色与前景(文字 / hover / 选中 / 标题)全部由 DefaultLayout 的 .layout
调色板按 .dark / .semi-dark-sidebar 整体切换 —— 本列是 .layout 的子节点,
直接继承那组 --sidebar-*。
早期这里只刷了 background / border,没管前景,于是「深色侧栏 + 双列菜单」下
文字仍是浅色档的深灰 #1f2329,压在 #1d2129 上(实测对比度 1.89,看不见)。
⚠️ is-dark 这个类本身**要保留**(模板上仍在):styles/skin-shadcn.scss:122
用 `html[data-g3-style='shadcn'] .layout-mixed-rail.is-dark` 提权覆盖色板,
删掉类会让 shadcn 皮肤的深色侧栏静默失效。 */
}
</style>
+313 -5
View File
@@ -1,10 +1,318 @@
<script setup lang="ts">
import DomainPlaceholder from '@/components/DomainPlaceholder.vue'
import { ref, watch } from 'vue'
import { G3Message, G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
/**
* 订舱单(海运)—— G3Table 使用范例。
*
* 三个要点:
* 1. 假数据:本地生成,不请求接口
* 2. 条数可调:默认 1000 行,用 rowCount 决定数据量
* 3. 行编辑:列上配 editor,单击进入;Enter 全选内容(便于覆写)、
* Esc 取消并恢复原值、失焦提交;值变化经 change 事件回写到本地数据
*
* 不分页:1000 行直接交给表格,由 G3Table 内部的**虚拟滚动门控**兜住性能
* (> 100 行才开启,DOM 里只渲染视口内若干行)。
*
* 需要留意 G3Table 的两条行为(文档「实现说明」里有写):
* - rowKey 默认取 'id',数据里必须存在该字段且唯一,否则编辑态会串行
* - 列宽/列序拖拽要求 v-model:columns 绑定;本例不需要,故不绑(组件会自动关掉)
*/
defineOptions({ name: 'OceanBooking' })
interface BookingRow extends TableRow {
id: number
bookingNo: string
customer: string
pol: string
pod: string
vessel: string
etd: string
containerCount: number
status: number
}
/* ---------------- 假数据 ---------------- */
const PORT_OPTIONS = [
{ label: '上海', value: 'CNSHA' },
{ label: '宁波', value: 'CNNGB' },
{ label: '深圳', value: 'CNSZX' },
{ label: '青岛', value: 'CNTAO' },
{ label: '新加坡', value: 'SGSIN' },
{ label: '釜山', value: 'KRPUS' },
]
const STATUS_OPTIONS = [
{ label: '待确认', value: 0 },
{ label: '已确认', value: 1 },
{ label: '已放舱', value: 2 },
{ label: '已取消', value: 3 },
]
const CUSTOMERS = ['中远海运', '东方海外', '马士基', '长荣海运', '赫伯罗特', '达飞轮船']
const VESSELS = ['EVER GIVEN', 'MSC OSCAR', 'COSCO SHIPPING', 'ONE APOLLO', 'HMM ALGECIRAS']
/** 简易可复现伪随机:同一个 index 永远得到同一行,翻页时数据不跳变。 */
function seeded(n: number): number {
const x = Math.sin(n * 12.9898) * 43758.5453
return x - Math.floor(x)
}
function pick<T>(list: T[], n: number): T {
return list[Math.floor(seeded(n) * list.length) % list.length]
}
/** 生成第 index 条假订舱单 */
function makeRow(index: number): BookingRow {
const m = String((index % 12) + 1).padStart(2, '0')
const d = String(((index * 3) % 27) + 1).padStart(2, '0')
return {
id: index + 1,
bookingNo: `BK${String(20260000 + index + 1)}`,
customer: 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),
}
}
/* ---------------- 数据量 ---------------- */
/** 数据条数:改这个值就等于「指定数据有多少」 */
const rowCount = ref(1000)
/**
* 假数据。
*
* 不做分页:1000 行**一次性**交给表格。G3Table 内部按行数门控虚拟滚动
* (> 100 行才开启),所以这里会走虚拟滚动路径,DOM 里只渲染视口内的行,
* 1000 行与 10 行的滚动开销几乎无差别。
*
* ⚠ 必须用 `ref`(深层响应式),**不能用 `computed` 返回普通对象数组**。
*
* 行内编辑是靠「直接改 row 对象上的字段」回写的(见 utils/editable.ts 的
* onUpdate:modelValue → row[dataIndex] = value)。若行对象不是响应式代理,
* 这次写入**不会触发任何重渲染** —— 表现极具迷惑性:
* · change 事件照常抛出(数据确实改了)
* · 但界面上仍旧显示旧值
* · 点一下别处(触发无关重渲染)才"突然"更新
* 实测:computed 数组里 row.pol 从 CNSHA 改成 CNNGB 后,
* G3Select 收到的 modelValue 仍是 CNSHA,界面纹丝不动。
*
* ref 会递归代理内部对象,行字段的读写都可被追踪,编辑器选完即刷新。
*/
const rows = ref<BookingRow[]>([])
watch(
rowCount,
(n) => {
rows.value = Array.from({ length: n }, (_, i) => makeRow(i))
},
{ immediate: true },
)
/* ---------------- 列定义 ---------------- */
/**
* 列定义用 `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)。
*/
const columns = ref<G3TableColumn<TableRow>[]>([
{ dataIndex: 'bookingNo', title: '订舱号', width: 130 },
{
dataIndex: 'customer',
title: '客户',
width: 140,
// 文本编辑器:单击进入,失焦提交,Esc 取消并恢复原值
editor: { type: 'text' },
},
{
dataIndex: 'pol',
title: '起运港',
width: 120,
// select 编辑器:options 传数组;非编辑态会把 value 解析成 label 展示
editor: { type: 'select', options: PORT_OPTIONS },
},
{
dataIndex: 'pod',
title: '目的港',
width: 120,
editor: { type: 'select', options: PORT_OPTIONS },
},
{ dataIndex: 'vessel', title: '船名', width: 170, editor: { type: 'text' } },
{ dataIndex: 'etd', title: '开航日', width: 120, editor: { type: 'text' } },
{
dataIndex: 'containerCount',
title: '箱量',
width: 100,
align: 'right',
// 数字编辑器
editor: { type: 'number' },
},
{
dataIndex: 'status',
title: '状态',
width: 120,
editor: { type: 'select', options: STATUS_OPTIONS },
},
])
/* ---------------- 编辑回写 ---------------- */
const lastChange = ref('')
/**
* 行编辑后把值写回本地数据。
*
* G3Table 在编辑时会**直接改行对象**(编辑器组件内部已写入),这里主要是:
* 1. 拿到「哪一行、哪一列、改成了什么」,对接后端时在这里发接口;
* 2. 只有值**真的变化**才会触发 —— 组件按编辑开始时的快照做了去重,
* 「点进去又点出来」不会产生无意义的 change。
*
* ⚠ 入参类型必须是 `TableRow`,不能写 `BookingRow`:
* 组件的 emit 签名是 `(e: 'change', row: TableRow, col: G3TableColumn<TableRow>, ...)`,
* 事件处理器的参数类型直接来自它,写窄了会报 TS2322(与上面 columns 同因)。
* 需要具体字段时在函数体内断言,见下。
*/
function onChange(row: TableRow, col: G3TableColumn<TableRow>, value: unknown) {
const booking = row as BookingRow
lastChange.value = `${booking.bookingNo} 的「${col.title}」改为 ${value}`
}
function onRefresh() {
G3Message.success(`已刷新(共 ${rowCount.value} 条)`)
}
</script>
<template>
<DomainPlaceholder
title="订舱单"
:features="['订舱单录入与修改', '舱位确认', '订舱状态流转', '与提单关联']"
/>
<div class="booking">
<div class="booking__toolbar">
<h2 class="booking__title">订舱单</h2>
<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>
</select>
</label>
<button class="booking__btn" type="button" @click="onRefresh">刷新</button>
</div>
<p class="booking__tip">
单击单元格进入编辑 · 失焦(点别处)提交 · Enter 全选内容 · Esc 取消并恢复原值 ——
<template v-if="lastChange">
最近一次修改:<strong>{{ lastChange }}</strong>
</template>
<template v-else>(还没改过)</template>
</p>
<!-- 表格区域:G3Table 需要父级有确定高度,这里用 flex:1 撑开 -->
<div class="booking__table">
<G3Table
:columns="columns"
:data-source="rows"
row-key="id"
:row-height="32"
@change="onChange"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.booking {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 16px;
background: var(--g3-color-bg-container, #fff);
border: 1px solid var(--g3-color-border, #e5e6eb);
border-radius: 8px;
&__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
flex: none;
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--g3-color-text, #1f2329);
}
&__field {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--g3-color-text-secondary, #4e5969);
}
&__select {
height: 30px;
padding: 0 8px;
font-size: 13px;
color: var(--g3-color-text, #1f2329);
background: var(--g3-color-bg-container, #fff);
border: 1px solid var(--g3-color-border, #e5e6eb);
border-radius: 6px;
outline: none;
cursor: pointer;
&:focus {
border-color: var(--g3-color-primary, #165dff);
}
}
&__btn {
height: 30px;
padding: 0 14px;
font-size: 13px;
color: #fff;
cursor: pointer;
background: var(--g3-color-primary, #165dff);
border: 0;
border-radius: 6px;
&:hover {
opacity: 0.88;
}
}
&__tip {
margin: 12px 0;
font-size: 13px;
color: var(--g3-color-text-secondary, #4e5969);
flex: none;
}
/* 表格容器必须有确定高度:G3Table 内部按容器高度算虚拟滚动与滚动区 */
&__table {
flex: 1;
min-height: 0;
}
}
</style>