u
This commit is contained in:
1 parent
e434374db5
commit
b4498e5350
31 files changed
+2007
-294
No files matched your search
@@ -17,9 +17,13 @@
|
||||
|
||||
<!-- 字段顺序:时间 级别 [traceId] [orgId/userId] method uri logger: message
|
||||
注意 logger 不补齐宽度。只有访问日志的 logger 是短名 ACCESS,
|
||||
若写成 %-40.40logger 会在它后面补 34 个空格,出现一大段无意义空白 -->
|
||||
若写成 %-40.40logger 会在它后面补 34 个空格,出现一大段无意义空白
|
||||
%logger{60}:60 足以完整显示当前全部 logger 全名(最长约 58);
|
||||
写小了会逐级缩写包名(如 {39} 会把 WorkflowExpirySweepService 压成 c.g.e.s.w.…),
|
||||
短名字如 cn.g3soft.erp.ErpApiApplication 不超阈值则原样输出,
|
||||
因此同一日志里会出现“缩写/全名”混排 -->
|
||||
<property name="CONSOLE_PATTERN"
|
||||
value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p){} %clr([%X{traceId:-}]){magenta} %clr([%X{orgId:-}/%X{userId:-}]){green} %clr(%X{method:-} %X{uri:-}){faint} %clr(%logger{39}){cyan}%clr(:){faint} %m%n%wEx"/>
|
||||
value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p){} %clr([%X{traceId:-}]){magenta} %clr([%X{orgId:-}/%X{userId:-}]){green} %clr(%X{method:-} %X{uri:-}){faint} %clr(%logger{60}){cyan}%clr(:){faint} %m%n%wEx"/>
|
||||
|
||||
<!-- 控制台:彩色文本,MDC 内联 -->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -187,7 +187,10 @@ function onExpandLeave(el: Element): void {
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 叶子:跳转链接 -->
|
||||
<!-- 叶子:跳转链接。
|
||||
折叠态只渲染图标:侧栏只剩 collapseWidth 那么宽,图标+间距就占掉二十几个像素,
|
||||
名称会被裁成一两个字加省略号(比不显示更糟)。名称改由 title 提示,
|
||||
与分组分支的折叠态(G3Dropdown 触发器里同样只有图标)保持一致。 -->
|
||||
<RouterLink
|
||||
v-else
|
||||
class="layout-sidebar__item"
|
||||
@@ -201,6 +204,6 @@ function onExpandLeave(el: Element): void {
|
||||
@click="onLeafClick(item.path, $event)"
|
||||
>
|
||||
<component :is="item.icon" v-if="item.icon" class="layout-sidebar__icon" />
|
||||
<span class="layout-sidebar__label">{{ item.title }}</span>
|
||||
<span v-if="!collapsed" class="layout-sidebar__label">{{ item.title }}</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -367,12 +367,13 @@ async function onSubmit(): Promise<void> {
|
||||
|
||||
<G3FormItem name="password" label="密码">
|
||||
<!-- type="password" 时 G3Input 自带「显示 / 隐藏密码」按钮,无需自绘。
|
||||
密码非必填(对应 formRules 里没有 password 规则,label 也不会带红色星号),
|
||||
故占位文案标注「选填」,避免用户以为漏填会被拦下。 -->
|
||||
占位文案不写「选填」:与机构码 / 账号保持一致,避免暗示密码可随手填。
|
||||
注意密码【确实】不做前端必填校验(见 formRules 注释),
|
||||
所以这里只是文案统一,并未改变「空密码也能提交」的行为。 -->
|
||||
<G3Input
|
||||
v-model="formModel.password"
|
||||
type="password"
|
||||
placeholder="请输入密码(选填)"
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
:maxlength="32"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 自动编码面板(s_autocode):单条规则表单 + 实时预览(移植自 fms-vue)。
|
||||
*
|
||||
* 数据契约:v-model:autocode 绑定父级持有的规则草稿(s_autocode 行的数组,主键 b_module_id + b_field)。
|
||||
* 一个模块最多一条规则,面板就地修改行字段(改动直接落在父级草稿上,保存时才 diff 提交)。
|
||||
* b_currentperiod / b_currentvalue 是运行时流水状态(服务端维护),预览读取它们但从不回写。
|
||||
*
|
||||
* 惰性建行:模块从未配置自动编码时,草稿保持空数组——打开面板即建行会让「无变更」
|
||||
* 被 diff 成一条 insert;占位行只用于渲染与校验,用户首次操作(拨开关或改参数)才落行。
|
||||
*
|
||||
* 布局(5 列紧凑网格 + 纵排 label,横排时 88px 的标签会把控件压到几十像素):
|
||||
* ┌ 第一行:启用 / 业务编号字段 / 重置周期
|
||||
* ├ 第二行:前缀 / 日期 / 分隔符 / 位数 / 起始值
|
||||
* └ 第三行:编号预览(跨整行)
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { G3Form, G3FormItem, G3Input, G3Select, G3Switch } from '@g3soft/ui'
|
||||
import type { FormRule } from '@g3soft/ui'
|
||||
import type { ModuleAutoCodeRow, ModuleFieldRow } from './utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 模块编码(保存时写入规则行的 b_module_id) */
|
||||
moduleId: string
|
||||
/** 本模块字段清单(s_field):业务编号字段下拉的候选与类型校验依据 */
|
||||
fields?: ModuleFieldRow[]
|
||||
/** 父级忙碌态(加载 / 保存中):期间锁掉全部控件 */
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{ fields: () => [], loading: false },
|
||||
)
|
||||
|
||||
/** 自动编码规则草稿;空数组表示本模块未配置自动编码 */
|
||||
const autoCodeModel = defineModel<ModuleAutoCodeRow[]>('autocode', { default: () => [] })
|
||||
|
||||
const DATE_FORMAT_OPTIONS = [
|
||||
{ label: '不使用日期', value: '' },
|
||||
{ label: 'yyyy', value: 'yyyy' },
|
||||
{ label: 'yy', value: 'yy' },
|
||||
{ label: 'yyyyMM', value: 'yyyyMM' },
|
||||
{ label: 'yyMM', value: 'yyMM' },
|
||||
{ label: 'yyyyMMdd', value: 'yyyyMMdd' },
|
||||
{ label: 'yyyy-MM', value: 'yyyy-MM' },
|
||||
{ label: 'yyyy-MM-dd', value: 'yyyy-MM-dd' },
|
||||
{ label: 'yyyy/MM', value: 'yyyy/MM' },
|
||||
{ label: 'yyyy/MM/dd', value: 'yyyy/MM/dd' },
|
||||
]
|
||||
|
||||
const RESET_OPTIONS = [
|
||||
{ label: '不重置', value: 'none' },
|
||||
{ label: '每年', value: 'year' },
|
||||
{ label: '每月', value: 'month' },
|
||||
{ label: '每日', value: 'day' },
|
||||
]
|
||||
|
||||
/** 业务编号字段候选:启用中的文本类字段(编号要往这个字段写字符串) */
|
||||
function pickTextFieldOptions(fields: ModuleFieldRow[]) {
|
||||
return (fields || []).filter(
|
||||
(item) =>
|
||||
Number(item.b_canuse) === 1 &&
|
||||
String(item.b_field) !== 'b_id' &&
|
||||
(item.b_type === 'input' || item.b_type === 'textarea'),
|
||||
)
|
||||
}
|
||||
|
||||
/** 新建规则行的默认值(未启用态):日期 / 位数 / 重置周期给出常用初值 */
|
||||
function createDraft(
|
||||
moduleId: string,
|
||||
fields: ModuleFieldRow[],
|
||||
enabled = false,
|
||||
): ModuleAutoCodeRow {
|
||||
const firstField = pickTextFieldOptions(fields)[0]
|
||||
return {
|
||||
b_module_id: String(moduleId),
|
||||
b_field: firstField?.b_field != null ? String(firstField.b_field) : null,
|
||||
b_prefix: '',
|
||||
b_dateformat: 'yyyyMM',
|
||||
b_separator: '-',
|
||||
b_seqwidth: 5,
|
||||
b_resettype: 'month',
|
||||
b_startvalue: 1,
|
||||
b_currentperiod: null,
|
||||
b_currentvalue: 0,
|
||||
b_canuse: enabled ? 1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** 已落草稿的规则行;模块从未配置过自动编码时为 null */
|
||||
const localRow = computed<ModuleAutoCodeRow | null>(() =>
|
||||
autoCodeModel.value.length ? autoCodeModel.value[0] : null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 未配置时的渲染占位行:只用于展示与校验,不写回 model,
|
||||
* 避免打开面板即建行导致「无变更却 diff 出一条 insert」。
|
||||
*/
|
||||
const emptyRow = computed(() => createDraft(props.moduleId, props.fields, false))
|
||||
|
||||
/** 表单绑定行:优先真实行,未配置时回退占位行,保证「启用」开关始终可见可拨 */
|
||||
const formRow = computed<ModuleAutoCodeRow>(() => localRow.value ?? emptyRow.value)
|
||||
|
||||
const autoCodeEnabled = computed(() => Number(localRow.value?.b_canuse) === 1)
|
||||
|
||||
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
|
||||
const formRules = computed<Record<string, FormRule[]>>(() => {
|
||||
const field = (props.fields || []).find(
|
||||
(item) => String(item.b_field) === String(formRow.value?.b_field || ''),
|
||||
)
|
||||
return {
|
||||
b_field: [
|
||||
{
|
||||
validator: (value) => {
|
||||
if (!autoCodeEnabled.value) return true
|
||||
if (!value) return '请选择业务编号字段'
|
||||
if (!field || Number(field.b_canuse) !== 1) return '请选择已启用的业务编号字段'
|
||||
if (field.b_type !== 'input' && field.b_type !== 'textarea') {
|
||||
return '业务编号字段必须使用文本控件类型'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
],
|
||||
b_startvalue: [
|
||||
{
|
||||
type: 'integer',
|
||||
validator: (value) => {
|
||||
if (!autoCodeEnabled.value) return true
|
||||
if (value === '' || value == null) return '请填写起始值'
|
||||
if (Number(value) < 0) return '起始值必须是大于或等于 0 的整数'
|
||||
return true
|
||||
},
|
||||
message: '起始值必须是大于或等于 0 的整数',
|
||||
},
|
||||
],
|
||||
b_resettype: [{ required: true, message: '请选择重置周期' }],
|
||||
b_seqwidth: [
|
||||
{
|
||||
validator: (value) => {
|
||||
if (!autoCodeEnabled.value) return true
|
||||
const width = Number(value)
|
||||
if (value === '' || value == null) return '请填写流水号位数'
|
||||
if (!Number.isInteger(width) || width < 1 || width > 18) {
|
||||
return '流水号位数必须是 1 到 18 之间的整数'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const formRef = ref<InstanceType<typeof G3Form> | null>(null)
|
||||
|
||||
/** 校验全表:返回 [{ name, errors }],空数组即通过(供保存前调用) */
|
||||
async function validate() {
|
||||
return (await formRef.value?.validateAll()) ?? []
|
||||
}
|
||||
|
||||
defineExpose({ validate })
|
||||
|
||||
/** 业务编号字段下拉候选 */
|
||||
const fieldOptions = computed(() =>
|
||||
pickTextFieldOptions(props.fields).map((field) => ({
|
||||
label: `${field.b_name || field.b_field} (${field.b_field})`,
|
||||
value: String(field.b_field),
|
||||
})),
|
||||
)
|
||||
|
||||
/**
|
||||
* 惰性建行:用户首次操作(拨「启用」或改任意参数)时才落一行到草稿。
|
||||
* defineModel 写入后 prop 要下一轮才回传,紧接着回读仍是旧数组(空),
|
||||
* 直接取 [0] 会得到 undefined 再写属性就炸——先包成 reactive 再写回,
|
||||
* 返回其中的代理行,保证后续就地改字段能被追踪。
|
||||
*/
|
||||
function ensureRow(): ModuleAutoCodeRow {
|
||||
if (autoCodeModel.value.length) return autoCodeModel.value[0]
|
||||
const rows = reactive([createDraft(props.moduleId, props.fields, false)])
|
||||
autoCodeModel.value = rows
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
function toggleEnabled(checked: boolean) {
|
||||
ensureRow().b_canuse = checked ? 1 : 0
|
||||
}
|
||||
|
||||
/** 就地写草稿行字段:ensureRow 保证行已存在(用户首次操作时才落行) */
|
||||
function setRowValue(field: string, value: unknown) {
|
||||
ensureRow()[field] = value
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function formatDate(date: Date, format: string): string {
|
||||
const yyyy = String(date.getFullYear())
|
||||
const yy = yyyy.slice(-2)
|
||||
const MM = pad(date.getMonth() + 1)
|
||||
const dd = pad(date.getDate())
|
||||
return String(format || '')
|
||||
.replaceAll('yyyy', yyyy)
|
||||
.replaceAll('yy', yy)
|
||||
.replaceAll('MM', MM)
|
||||
.replaceAll('dd', dd)
|
||||
}
|
||||
|
||||
/** 当前周期标识:与规则同口径,用于判断存量流水是否已跨周期(跨了就从起始值重来) */
|
||||
function currentPeriod(date: Date, resetType: string): string {
|
||||
const yyyy = String(date.getFullYear())
|
||||
const MM = pad(date.getMonth() + 1)
|
||||
const dd = pad(date.getDate())
|
||||
if (resetType === 'none') return '*'
|
||||
if (resetType === 'year') return yyyy
|
||||
if (resetType === 'day') return yyyy + MM + dd
|
||||
return yyyy + MM
|
||||
}
|
||||
|
||||
/** 编码预览:前缀 + 日期 + 补位流水号,按分隔符拼接 */
|
||||
const preview = computed(() => {
|
||||
const row = formRow.value
|
||||
if (!row) return ''
|
||||
const today = new Date()
|
||||
const startValue = Number(row.b_startvalue) || 1
|
||||
const storedValue = Number(row.b_currentvalue) || 0
|
||||
const activePeriod = currentPeriod(today, row.b_resettype)
|
||||
const sequence = row.b_currentperiod === activePeriod ? storedValue + 1 : startValue
|
||||
const parts: string[] = []
|
||||
if (row.b_prefix) parts.push(row.b_prefix)
|
||||
const dateText = formatDate(today, row.b_dateformat || '')
|
||||
if (dateText) parts.push(dateText)
|
||||
parts.push(String(sequence).padStart(Number(row.b_seqwidth) || 1, '0'))
|
||||
return parts.join(row.b_separator || '')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 固定竖排:这里 5 列紧凑网格(每列约 130px),横排时 88px 的标签会把控件压到几十像素。
|
||||
显式传 layout 优先于全局配置(中文默认横排),只锁这一个面板。
|
||||
标签左对齐走 G3Form 的默认值(竖排下 label 占满整列,右对齐会把文字推到边缘)。 -->
|
||||
<G3Form
|
||||
ref="formRef"
|
||||
class="mm-auto-code"
|
||||
:model="formRow"
|
||||
:rules="formRules"
|
||||
layout="vertical"
|
||||
:columns="5"
|
||||
max-width="720px"
|
||||
>
|
||||
<!-- 第一行:启用 / 业务编号字段 / 重置周期 -->
|
||||
<G3FormItem label="启用" name="b_canuse">
|
||||
<G3Switch
|
||||
:model-value="autoCodeEnabled"
|
||||
:disabled="loading"
|
||||
@update:model-value="toggleEnabled"
|
||||
/>
|
||||
</G3FormItem>
|
||||
<G3FormItem label="业务编号字段" name="b_field" :span="2">
|
||||
<G3Select
|
||||
:model-value="String(formRow.b_field || '')"
|
||||
:options="fieldOptions"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
@update:model-value="(value) => setRowValue('b_field', value)"
|
||||
/>
|
||||
</G3FormItem>
|
||||
<G3FormItem label="重置周期" name="b_resettype" :span="2">
|
||||
<G3Select
|
||||
:model-value="formRow.b_resettype"
|
||||
:options="RESET_OPTIONS"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
@update:model-value="(value) => setRowValue('b_resettype', value)"
|
||||
/>
|
||||
</G3FormItem>
|
||||
|
||||
<!-- 第二行:前缀 / 日期 / 分隔符 / 位数 / 起始值 -->
|
||||
<G3FormItem label="前缀" name="b_prefix">
|
||||
<G3Input
|
||||
:model-value="formRow.b_prefix ?? ''"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
@update:model-value="(value) => setRowValue('b_prefix', value)"
|
||||
/>
|
||||
</G3FormItem>
|
||||
<G3FormItem label="日期" name="b_dateformat">
|
||||
<G3Select
|
||||
:model-value="formRow.b_dateformat ?? ''"
|
||||
:options="DATE_FORMAT_OPTIONS"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
allow-free-input
|
||||
@update:model-value="(value) => setRowValue('b_dateformat', value)"
|
||||
/>
|
||||
</G3FormItem>
|
||||
<G3FormItem label="分隔符" name="b_separator">
|
||||
<G3Input
|
||||
:model-value="formRow.b_separator ?? ''"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
@update:model-value="(value) => setRowValue('b_separator', value)"
|
||||
/>
|
||||
</G3FormItem>
|
||||
<G3FormItem label="位数" name="b_seqwidth">
|
||||
<G3Input
|
||||
:model-value="formRow.b_seqwidth"
|
||||
type="number"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
@update:model-value="(value) => setRowValue('b_seqwidth', value)"
|
||||
/>
|
||||
</G3FormItem>
|
||||
<G3FormItem label="起始值" name="b_startvalue">
|
||||
<G3Input
|
||||
:model-value="formRow.b_startvalue"
|
||||
type="number"
|
||||
:disabled="loading || !autoCodeEnabled"
|
||||
@update:model-value="(value) => setRowValue('b_startvalue', value)"
|
||||
/>
|
||||
</G3FormItem>
|
||||
|
||||
<!-- 第三行:编号预览(只读,跨整行;未启用时整体淡化) -->
|
||||
<G3FormItem
|
||||
label="编号预览"
|
||||
name="b_preview"
|
||||
:span="5"
|
||||
class="mm-auto-code__preview-item"
|
||||
:class="{ 'is-disabled': !autoCodeEnabled }"
|
||||
>
|
||||
<G3Input :model-value="preview" readonly :disabled="loading" />
|
||||
</G3FormItem>
|
||||
</G3Form>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mm-auto-code {
|
||||
padding: 4px 0 16px;
|
||||
}
|
||||
|
||||
/* 未启用自动编码:预览整体淡化 */
|
||||
.mm-auto-code__preview-item {
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.mm-auto-code__preview-item.is-disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
@@ -7,23 +7,20 @@
|
||||
* 工具)与「同步字段」(describeApi + 合并计划 + 失效节点回收)随后续步骤接入,
|
||||
* 本步只做表格与列编辑。
|
||||
*
|
||||
* 「同步字段」按钮上提到了页面级的面板头(右下内容卡的右上操作区),与其它面板的动作
|
||||
* 放同一处,所以本面板不再自带工具条;真正的同步链路落地时由面板通过事件 / ref 提供动作。
|
||||
*
|
||||
* 列说明:字段名只读(b_field 即数据库列名,同步时对齐);字段名称 / 业务类型 / 启用
|
||||
* 可编辑(启用列的编辑器写 1/0,与 b_canuse 口径一致);多语言键为只读展示
|
||||
* (field.{模块编码}.{字段名} 的派生键物化随保存编排,不在加载时动草稿)。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
import { G3Button, G3Message, G3Table } from '@g3soft/ui'
|
||||
import { ref } from 'vue'
|
||||
import { G3Table } from '@g3soft/ui'
|
||||
import type { G3TableColumn, TableRow } from '@g3soft/ui'
|
||||
import type { ModuleFieldRow, ModuleNode } from './utils'
|
||||
import type { ModuleFieldRow } from './utils'
|
||||
|
||||
const fieldsModel = defineModel<ModuleFieldRow[]>('fields', { default: () => [] })
|
||||
|
||||
const props = defineProps<{
|
||||
/** 模块主表行(只读):「同步字段」按 b_view_table 是否存在决定可用性 */
|
||||
module?: ModuleNode | null
|
||||
}>()
|
||||
|
||||
/** 业务类型候选(v2 的 s_field 只存业务元数据,不存物理结构) */
|
||||
const FIELD_TYPES = [
|
||||
{ label: '输入框', value: 'input' },
|
||||
@@ -58,25 +55,10 @@ const columns = ref<G3TableColumn<TableRow>[]>([
|
||||
|
||||
/** 当前高亮行(点行高亮,与原型口径一致) */
|
||||
const activeKey = ref<string | null>(null)
|
||||
|
||||
/** 同步按钮只在配置了查询表/视图时出现(没有同步源) */
|
||||
const syncVisible = computed(() => Boolean(props.module?.b_view_table))
|
||||
|
||||
/** 同步字段:按数据库查询表/视图结构对齐字段定义(随后续步骤移植) */
|
||||
function onSync() {
|
||||
G3Message.info('字段同步随后续步骤移植')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-fields">
|
||||
<div v-if="syncVisible" class="mm-fields__toolbar">
|
||||
<G3Button type="outline" title="按数据库查询表/视图结构对齐字段定义" @click="onSync">
|
||||
<template #icon><RefreshCw :size="14" /></template>
|
||||
同步字段
|
||||
</G3Button>
|
||||
</div>
|
||||
|
||||
<div class="mm-fields__table">
|
||||
<G3Table
|
||||
v-model:columns="columns"
|
||||
@@ -91,7 +73,7 @@ function onSync() {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 面板纵向结构:工具条(可无)→ 撑满的表格区 */
|
||||
/* 面板纵向结构:只有撑满的表格区(工具条已上提到页面级面板头) */
|
||||
.mm-fields {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -99,13 +81,6 @@ function onSync() {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mm-fields__toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 8px;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.mm-fields__table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -10,24 +10,8 @@
|
||||
*
|
||||
* 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块。
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
|
||||
import {
|
||||
CircleSlash,
|
||||
Ellipsis,
|
||||
FolderTree,
|
||||
Globe,
|
||||
Hash,
|
||||
Info,
|
||||
List,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Table2,
|
||||
Trash2,
|
||||
Waypoints,
|
||||
} from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Ellipsis, FolderTree, RefreshCw, Save, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
G3Button,
|
||||
G3Dropdown,
|
||||
@@ -55,6 +39,7 @@ import {
|
||||
sortTree,
|
||||
type TreeRow,
|
||||
} from '@/utils/tree'
|
||||
import ModuleAutoCodePanel from './ModuleAutoCodePanel.vue'
|
||||
import ModuleBasicPanel from './ModuleBasicPanel.vue'
|
||||
import ModuleFieldsPanel from './ModuleFieldsPanel.vue'
|
||||
import ModuleTreePanel from './ModuleTreePanel.vue'
|
||||
@@ -65,6 +50,7 @@ import {
|
||||
buildModuleItemI18nKey,
|
||||
collectSchemaGroupKeys,
|
||||
sqlStringLiteral,
|
||||
type ModuleAutoCodeRow,
|
||||
type ModuleFieldRow,
|
||||
type ModuleNode,
|
||||
} from './utils'
|
||||
@@ -240,7 +226,8 @@ type SectionScope = 'always' | 'config' | 'data'
|
||||
interface ConfigSection {
|
||||
key: string
|
||||
label: string
|
||||
icon: Component
|
||||
/** 面板头里标题下面的一句说明(右下内容卡的左上角) */
|
||||
desc: string
|
||||
scope: SectionScope
|
||||
}
|
||||
|
||||
@@ -248,18 +235,75 @@ interface ConfigSection {
|
||||
* 右侧配置区块定义:数组顺序即标签顺序。
|
||||
* 原型的分组(概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置)只用来决定先后顺序,
|
||||
* 平铺成一行标签、不显示组名——10 个标签 + 组名会多一层噪音。
|
||||
*
|
||||
* 标签不带图标:数据模块下 10 个标签全显示,图标让每个标签多出 18px、
|
||||
* 总宽超出卡片可用宽度(末两个标签被截、而标签栏滚动条是隐藏的),
|
||||
* 且 Table2 / List / Search 这类图标本身也分不出来。
|
||||
*
|
||||
* desc 用于右下内容卡的面板头(左上标题 + 说明),与标签栏分离:
|
||||
* 标签栏只回答「在哪一页」,面板头回答「这一页是干什么的」。
|
||||
*/
|
||||
const CONFIG_SECTIONS: ConfigSection[] = [
|
||||
{ key: 'basic', label: '基础信息', icon: Info, scope: 'always' },
|
||||
{ key: 'fields', label: '字段定义', icon: Table2, scope: 'config' },
|
||||
{ key: 'edit', label: '表单设计', icon: Pencil, scope: 'config' },
|
||||
{ key: 'list', label: '列表配置', icon: List, scope: 'config' },
|
||||
{ key: 'query', label: '查询配置', icon: Search, scope: 'config' },
|
||||
{ key: 'auto-code', label: '自动编码', icon: Hash, scope: 'data' },
|
||||
{ key: 'power', label: '权限', icon: ShieldCheck, scope: 'data' },
|
||||
{ key: 'relation', label: '模块关联', icon: Waypoints, scope: 'data' },
|
||||
{ key: 'rule', label: '删除规则', icon: CircleSlash, scope: 'data' },
|
||||
{ key: 'i18n', label: '多语言', icon: Globe, scope: 'always' },
|
||||
{
|
||||
key: 'basic',
|
||||
label: '基础信息',
|
||||
desc: '维护模块编码、名称、类型与数据来源。',
|
||||
scope: 'always',
|
||||
},
|
||||
{
|
||||
key: 'fields',
|
||||
label: '字段定义',
|
||||
desc: '维护模块字段、业务类型与启用状态。',
|
||||
scope: 'config',
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: '表单设计',
|
||||
desc: '设计录入表单的布局与字段排布。',
|
||||
scope: 'config',
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
label: '列表配置',
|
||||
desc: '配置列表显示列、筛选项与默认排序。',
|
||||
scope: 'config',
|
||||
},
|
||||
{
|
||||
key: 'query',
|
||||
label: '查询配置',
|
||||
desc: '配置查询条件与默认查询参数。',
|
||||
scope: 'config',
|
||||
},
|
||||
{
|
||||
key: 'auto-code',
|
||||
label: '自动编码',
|
||||
desc: '配置编号生成规则、编码片段与流水号策略。',
|
||||
scope: 'data',
|
||||
},
|
||||
{
|
||||
key: 'power',
|
||||
label: '权限',
|
||||
desc: '按角色配置模块的访问与数据范围。',
|
||||
scope: 'data',
|
||||
},
|
||||
{
|
||||
key: 'relation',
|
||||
label: '模块关联',
|
||||
desc: '维护本模块与其他模块的引用关系。',
|
||||
scope: 'data',
|
||||
},
|
||||
{
|
||||
key: 'rule',
|
||||
label: '删除规则',
|
||||
desc: '配置删除单据时的连带处理规则。',
|
||||
scope: 'data',
|
||||
},
|
||||
{
|
||||
key: 'i18n',
|
||||
label: '多语言',
|
||||
desc: '集中维护模块、字段与提示语的各语言译文。',
|
||||
scope: 'always',
|
||||
},
|
||||
]
|
||||
|
||||
const isDataModule = computed(() => selectedModule.value?.b_module_type === 'data')
|
||||
@@ -277,14 +321,11 @@ const configSections = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
/** G3Tabs 的 items(label 用字符串保底;图标在 #label 插槽里按 key 查回来渲染) */
|
||||
/** G3Tabs 的 items:标签只用文字,图标不再带(见 CONFIG_SECTIONS 的说明) */
|
||||
const configNavItems = computed(() =>
|
||||
configSections.value.map(({ key, label }) => ({ key, label })),
|
||||
)
|
||||
|
||||
const sectionByKey = (key: unknown) =>
|
||||
CONFIG_SECTIONS.find((section) => section.key === String(key))
|
||||
|
||||
/** 当前激活的配置区块 */
|
||||
const activeSection = ref('basic')
|
||||
|
||||
@@ -303,10 +344,17 @@ const headModule = computed(() => moduleDraft.value ?? selectedModule.value)
|
||||
/** 选中模块的字段清单(「数据范围字段」下拉候选,s_field 的最小投影) */
|
||||
const moduleFields = ref<ModuleFieldRow[]>([])
|
||||
|
||||
/** 选中变化:重建草稿并拉字段清单(异步响应带过期保护,切走即丢弃) */
|
||||
/** 选中模块的自动编码规则草稿(s_autocode,一模块至多一条;空数组即未配置) */
|
||||
const moduleAutoCode = ref<ModuleAutoCodeRow[]>([])
|
||||
|
||||
/** 自动编码面板实例:保存前调它的 validate() 拿表单错误 */
|
||||
const autoCodePanelRef = ref<InstanceType<typeof ModuleAutoCodePanel> | null>(null)
|
||||
|
||||
/** 选中变化:重建草稿并拉字段 / 自动编码(异步响应带过期保护,切走即丢弃) */
|
||||
watch(moduleSelectedId, (id) => {
|
||||
moduleDraft.value = id == null ? null : toModuleDraft(String(id))
|
||||
void loadModuleFields(id)
|
||||
void loadModuleAutoCode(id)
|
||||
})
|
||||
|
||||
function toModuleDraft(id: string): ModuleNode | null {
|
||||
@@ -332,6 +380,29 @@ async function loadModuleFields(id: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载本模块的自动编码规则(s_autocode)。
|
||||
* 只有数据模块有保存表、编号才有落库位置,其余模块直接按「未配置」处理,不发请求。
|
||||
*/
|
||||
async function loadModuleAutoCode(id: string | null) {
|
||||
moduleAutoCode.value = []
|
||||
if (id == null) return
|
||||
if (findModuleNode(id)?.b_module_type !== 'data') return
|
||||
const requested = String(id)
|
||||
try {
|
||||
const rows = await loadDataApi(
|
||||
's_autocode',
|
||||
`b_module_id = ${sqlStringLiteral(requested)}`,
|
||||
'b_field ASC',
|
||||
)
|
||||
// 选择已切走时丢弃过期响应
|
||||
if (String(moduleSelectedId.value ?? '') !== requested) return
|
||||
moduleAutoCode.value = (rows ?? []) as ModuleAutoCodeRow[]
|
||||
} catch {
|
||||
// 规则加载失败不打断页面:面板按「未配置」展示
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 卡头动作 ================= */
|
||||
|
||||
/** 卡头「更多」菜单:模块级动作(删除与左侧树右键菜单是同一入口) */
|
||||
@@ -345,8 +416,34 @@ function onMoreAction({ key }: DropdownClickPayload) {
|
||||
if (id != null) void deleteModule(String(id))
|
||||
}
|
||||
|
||||
/** 卡头「保存」:保存编排(校验 → 逐表 diff → 重载)随配置面板一起移植 */
|
||||
function onSave() {
|
||||
/**
|
||||
* 自动编码的跨面板前置条件:启用编码的模块必须先配置保存表。
|
||||
* 编号是在保存记录时写进去的,没有保存表就没有落库位置,规则配了也不会生效。
|
||||
*/
|
||||
function validateAutoCode(): string {
|
||||
const rule = moduleAutoCode.value[0]
|
||||
if (!rule || Number(rule.b_canuse) !== 1) return ''
|
||||
if (!String(moduleDraft.value?.b_save_table ?? '').trim()) return '启用自动编码前请先配置保存表'
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡头「保存」:保存编排(校验 → 逐表 diff → 重载)随配置面板一起移植。
|
||||
* 目前先把自动编码的校验串上:有错就跳到对应标签并高亮。
|
||||
*/
|
||||
async function onSave() {
|
||||
const autoCodeErrors = await autoCodePanelRef.value?.validate()
|
||||
if (autoCodeErrors?.length) {
|
||||
activeSection.value = 'auto-code'
|
||||
G3Message.warning('请修正表单中标红的错误项')
|
||||
return
|
||||
}
|
||||
const autoCodeError = validateAutoCode()
|
||||
if (autoCodeError) {
|
||||
activeSection.value = 'auto-code'
|
||||
G3Message.warning(autoCodeError)
|
||||
return
|
||||
}
|
||||
G3Message.info('保存编排随配置面板逐步移植')
|
||||
}
|
||||
|
||||
@@ -355,6 +452,17 @@ function onRefresh() {
|
||||
G3Message.info('配置重新加载随配置面板逐步移植')
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段定义面板的「同步字段」:按数据库查询表 / 视图结构对齐字段定义。
|
||||
*
|
||||
* 按钮挂在页面级的面板头(右下内容卡的右上操作区),与其它面板的动作放同一处;
|
||||
* 真正的同步链路(describeApi + 合并计划 + 失效节点回收)落地时再回到
|
||||
* ModuleFieldsPanel —— 届时由面板通过事件 / ref 提供动作,这里保持薄壳。
|
||||
*/
|
||||
function onSyncFields() {
|
||||
G3Message.info('字段同步随后续步骤移植')
|
||||
}
|
||||
|
||||
/** 收集删除模块时需要清理的 s_i18n 资源键(字段键 + 界面配置分组键) */
|
||||
async function collectModuleI18nKeys(moduleId: string): Promise<string[]> {
|
||||
const keys: string[] = []
|
||||
@@ -540,52 +648,54 @@ onMounted(() => {
|
||||
<G3SplitterPanel>
|
||||
<main class="mm-page__main">
|
||||
<section v-if="selectedModule" class="mm-card">
|
||||
<!-- 卡头常驻:左侧模块信息,右侧动作按钮(刷新 / 保存 / 更多),不随配置区滚动 -->
|
||||
<header class="mm-card__head">
|
||||
<div class="mm-card__info">
|
||||
<span class="mm-card__name" :title="headModule?.b_name || ''">
|
||||
{{ headModule?.b_name || '未命名模块' }}
|
||||
</span>
|
||||
<G3Tag :variant="Number(headModule?.b_canuse) === 1 ? 'success' : 'danger'">
|
||||
{{ Number(headModule?.b_canuse) === 1 ? '启用' : '停用' }}
|
||||
</G3Tag>
|
||||
<span v-if="unsavedModuleIds.has(String(headModule?.b_id))" class="mm-card__draft">
|
||||
未保存·保存流程随配置面板移植
|
||||
</span>
|
||||
</div>
|
||||
<div class="mm-card__actions">
|
||||
<G3Button type="outline" title="重新加载当前模块配置" @click="onRefresh">
|
||||
<template #icon><RefreshCw :size="14" /></template>
|
||||
刷新
|
||||
</G3Button>
|
||||
<G3Button type="primary" title="保存当前模块的全部配置" @click="onSave">
|
||||
<template #icon><Save :size="14" /></template>
|
||||
保存
|
||||
</G3Button>
|
||||
<G3Dropdown :items="moreMenuItems" @click="onMoreAction">
|
||||
<G3Button type="outline" title="更多操作" aria-label="更多操作">
|
||||
<Ellipsis :size="16" />
|
||||
<!-- 上块:卡头(模块信息 + 操作)与配置导航是一体的,同一张白卡 -->
|
||||
<div class="mm-card__top">
|
||||
<!-- 卡头常驻:左侧模块信息,右侧动作按钮(刷新 / 保存 / 更多),不随内容区滚动 -->
|
||||
<header class="mm-card__head">
|
||||
<div class="mm-card__info">
|
||||
<span class="mm-card__name" :title="headModule?.b_name || ''">
|
||||
{{ headModule?.b_name || '未命名模块' }}
|
||||
</span>
|
||||
<G3Tag :variant="Number(headModule?.b_canuse) === 1 ? 'success' : 'danger'">
|
||||
{{ Number(headModule?.b_canuse) === 1 ? '启用' : '停用' }}
|
||||
</G3Tag>
|
||||
<span
|
||||
v-if="unsavedModuleIds.has(String(headModule?.b_id))"
|
||||
class="mm-card__draft"
|
||||
>
|
||||
未保存·保存流程随配置面板移植
|
||||
</span>
|
||||
</div>
|
||||
<div class="mm-card__actions">
|
||||
<G3Button type="outline" title="重新加载当前模块配置" @click="onRefresh">
|
||||
<template #icon><RefreshCw :size="14" /></template>
|
||||
刷新
|
||||
</G3Button>
|
||||
</G3Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
<!-- 配置导航:横向标签,只渲染标签栏;标签集随模块类型动态增减,
|
||||
溢出时标签栏自身横向滚动(G3Tabs 内置) -->
|
||||
<G3Tabs
|
||||
v-model="activeSection"
|
||||
nav-only
|
||||
:items="configNavItems"
|
||||
class="mm-config-nav"
|
||||
aria-label="模块配置导航"
|
||||
>
|
||||
<template #label="{ item }">
|
||||
<component :is="sectionByKey(item)?.icon" :size="14" class="mm-config-nav__icon" />
|
||||
<span>{{ sectionByKey(item)?.label }}</span>
|
||||
</template>
|
||||
</G3Tabs>
|
||||
<G3Button type="primary" title="保存当前模块的全部配置" @click="onSave">
|
||||
<template #icon><Save :size="14" /></template>
|
||||
保存
|
||||
</G3Button>
|
||||
<G3Dropdown :items="moreMenuItems" @click="onMoreAction">
|
||||
<G3Button type="outline" title="更多操作" aria-label="更多操作">
|
||||
<Ellipsis :size="16" />
|
||||
</G3Button>
|
||||
</G3Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
<!-- 卡头与配置导航之间的分割线:通栏(横贯整张白卡) -->
|
||||
<div class="mm-card__divider" aria-hidden="true" />
|
||||
<!-- 配置导航:横向标签,只渲染标签栏;标签集随模块类型动态增减,
|
||||
溢出时标签栏自身横向滚动(G3Tabs 内置) -->
|
||||
<G3Tabs
|
||||
v-model="activeSection"
|
||||
nav-only
|
||||
:items="configNavItems"
|
||||
class="mm-config-nav"
|
||||
aria-label="模块配置导航"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 内容区:所有 pane 常驻、v-show 切换——切标签不丢草稿与滚动位置;
|
||||
具体面板按 fms-vue 逐节填进对应 pane -->
|
||||
<!-- 下块:内容白卡。切标签换的是这里的内容(不同面板的表单) -->
|
||||
<div class="mm-config-stack">
|
||||
<div
|
||||
v-for="section in configSections"
|
||||
@@ -593,6 +703,27 @@ onMounted(() => {
|
||||
:key="section.key"
|
||||
class="mm-config-pane"
|
||||
>
|
||||
<!-- 面板头:左上标题 + 说明,右上该面板自己的操作 -->
|
||||
<div class="mm-panel-head">
|
||||
<div class="mm-panel-head__text">
|
||||
<div class="mm-panel-head__title">{{ section.label }}</div>
|
||||
<div class="mm-panel-head__desc">{{ section.desc }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="section.key === 'fields' && moduleDraft?.b_view_table"
|
||||
class="mm-panel-head__actions"
|
||||
>
|
||||
<G3Button
|
||||
type="outline"
|
||||
title="按数据库查询表/视图结构对齐字段定义"
|
||||
@click="onSyncFields"
|
||||
>
|
||||
<template #icon><RefreshCw :size="14" /></template>
|
||||
同步字段
|
||||
</G3Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基础信息:第一个真实面板(保存编排落地前先只做 UI,编辑只落在本地草稿) -->
|
||||
<ModuleBasicPanel
|
||||
v-if="section.key === 'basic'"
|
||||
@@ -600,11 +731,18 @@ onMounted(() => {
|
||||
:fields="moduleFields"
|
||||
:code-readonly="!unsavedModuleIds.has(String(selectedModule.b_id))"
|
||||
/>
|
||||
<!-- 字段定义:可编辑表格(参与勾选与同步字段随后续步骤接入) -->
|
||||
<!-- 字段定义:可编辑表格(勾选与同步字段随后续步骤接入) -->
|
||||
<ModuleFieldsPanel
|
||||
v-else-if="section.key === 'fields'"
|
||||
v-model:fields="moduleFields"
|
||||
:module="moduleDraft"
|
||||
/>
|
||||
<!-- 自动编码:仅数据模块(configSections 已按 scope 过滤),单条规则 + 实时预览 -->
|
||||
<ModuleAutoCodePanel
|
||||
v-else-if="section.key === 'auto-code'"
|
||||
ref="autoCodePanelRef"
|
||||
v-model:autocode="moduleAutoCode"
|
||||
:module-id="String(moduleDraft?.b_id ?? '')"
|
||||
:fields="moduleFields"
|
||||
/>
|
||||
<div v-else class="mm-config-placeholder">
|
||||
{{ section.label }}面板将按 fms-vue 逐步移植
|
||||
@@ -648,25 +786,44 @@ onMounted(() => {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 右侧白卡不带边框(与左树面板一致);滚动交给当前显示的配置 pane */
|
||||
/* 右侧分两块,各是一张白卡:
|
||||
* 上块 —— 卡头(模块信息 + 操作)与配置导航一体
|
||||
* 下块 —— 内容卡,切标签换的就是这里的表单内容
|
||||
* 外层只是「分栏容器」:不带底色、不带圆角,只负责上下堆叠与间距。 */
|
||||
.mm-card {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 上块:卡头 + 分割线 + 配置导航。
|
||||
* 内边距不放在本块上:分割线要通栏(横贯整张白卡),左右留白交给卡头与标签栏各自负责;
|
||||
* 底部也不留 —— 标签栏贴住本块底边,选中项那条 2px 主色下划线正好落在底边上。 */
|
||||
.mm-card__top {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
background: var(--g3-color-bg-container, #fff);
|
||||
border-radius: var(--g3-radius-base, 0.5rem);
|
||||
}
|
||||
|
||||
/* 卡头常驻:左侧模块信息,右侧动作按钮;不随配置区滚动 */
|
||||
/* 卡头常驻:左上标题、右上操作;不随内容区滚动 */
|
||||
.mm-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
/* 下内边距 12px:与分割线之间留出呼吸 */
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
|
||||
/* 卡头与内容之间的分割线:通栏、与页面其它分隔线同色 */
|
||||
.mm-card__divider {
|
||||
flex: none;
|
||||
height: 1px;
|
||||
background: var(--g3-color-border, #e5e6eb);
|
||||
}
|
||||
|
||||
.mm-card__info {
|
||||
@@ -700,18 +857,62 @@ onMounted(() => {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 配置导航(横向标签,nav-only):标签栏自身可横向滚动,窄屏不会把卡片撑破 */
|
||||
/* 配置导航(横向标签,nav-only):标签栏自身可横向滚动,窄屏不会把卡片撑破。
|
||||
* 左右留白自己负责(不在 .mm-card__top 上),上下不留 —— 标签栏贴住上块底边。
|
||||
* 那条 1px 底线压成透明:块内已经有卡头分割线,再来一条就是第二条边界;
|
||||
* 悬停变深 / 选中主色的反馈不受影响(那条走 --g3-color-primary)。 */
|
||||
.mm-config-nav {
|
||||
flex: none;
|
||||
padding: 0 16px;
|
||||
--g3-border-color: transparent;
|
||||
}
|
||||
|
||||
/* 内容区:所有 pane 常驻堆叠(v-show 切换);滚动交给当前显示的 pane */
|
||||
/* 下块内容卡:所有 pane 常驻堆叠(v-show 切换),滚动交给当前显示的 pane。
|
||||
* overflow: hidden 让圆角裁住里面的内容区。 */
|
||||
.mm-config-stack {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
background: var(--g3-color-bg-container, #fff);
|
||||
border-radius: var(--g3-radius-base, 0.5rem);
|
||||
}
|
||||
|
||||
/* 面板头(右下内容卡的左上角):标题 + 说明在左,该面板自己的操作在右。
|
||||
* 与标签栏分工不同 —— 标签栏只回答「在哪一页」,面板头回答「这一页是干什么的」。 */
|
||||
.mm-panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mm-panel-head__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mm-panel-head__title {
|
||||
color: var(--g3-color-text, #1f2329);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mm-panel-head__desc {
|
||||
margin-top: 4px;
|
||||
color: var(--g3-color-text-tertiary, #86909c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 面板级操作:靠右且不参与压缩 */
|
||||
.mm-panel-head__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mm-config-pane {
|
||||
|
||||
@@ -49,11 +49,48 @@ export type ModuleNode = TreeRow & {
|
||||
b_bz: string | null
|
||||
}
|
||||
|
||||
/** s_field 行的最小投影:基础信息面板「数据范围字段」候选只需要这三个字段 */
|
||||
/**
|
||||
* s_field 行的最小投影:基础信息面板「数据范围字段」候选只需要名称,
|
||||
* 自动编码面板还要按业务类型 / 启用态过滤「业务编号字段」候选。
|
||||
*/
|
||||
export interface ModuleFieldRow {
|
||||
b_field?: string | null
|
||||
b_name?: string | null
|
||||
b_i18n?: string | null
|
||||
/** 业务类型(input / textarea / number / money / date / datetime / checkbox / select) */
|
||||
b_type?: string | null
|
||||
/** 启用态(1 启用 / 0 停用) */
|
||||
b_canuse?: number | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* s_autocode 行:自动编码规则,主键 b_module_id + b_field(一个模块至多一条规则)。
|
||||
* b_currentperiod / b_currentvalue 是运行时流水状态,由服务端维护,配置侧只读。
|
||||
*/
|
||||
export interface ModuleAutoCodeRow {
|
||||
b_module_id: string
|
||||
/** 业务编号字段(编号写入哪个字段) */
|
||||
b_field: string | null
|
||||
/** 编码前缀 */
|
||||
b_prefix: string | null
|
||||
/** 日期格式段(yyyyMM 等),空串表示不使用日期 */
|
||||
b_dateformat: string | null
|
||||
/** 各段之间的分隔符 */
|
||||
b_separator: string | null
|
||||
/** 流水号位数(左补 0) */
|
||||
b_seqwidth: number
|
||||
/** 重置周期:none 不重置 / year 每年 / month 每月 / day 每日 */
|
||||
b_resettype: string
|
||||
/** 每周期起始流水值 */
|
||||
b_startvalue: number
|
||||
/** 当前周期标识(运行时状态) */
|
||||
b_currentperiod: string | null
|
||||
/** 当前周期内已用到的流水值(运行时状态) */
|
||||
b_currentvalue: number
|
||||
/** 是否启用(1 / 0) */
|
||||
b_canuse: number
|
||||
/** 索引签名:G3Form 的 model 契约是 Record<string, unknown>,按 FormItem 的 name 取值 */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 侧栏菜单项:折叠态只显示图标。
|
||||
*
|
||||
* 回归背景:分组分支在折叠态走的是 G3Dropdown(触发器里只有图标),
|
||||
* 而叶子分支的名称没跟着折叠态隐藏 —— 折叠后行里仍是「图标 + 名称」,
|
||||
* 侧栏只剩 collapseWidth 那么宽(图标+间距就占掉二十几像素),
|
||||
* 名称被裁成一两个字加省略号,比不显示更糟。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import SidebarMenuItem from '@/layouts/SidebarMenuItem.vue'
|
||||
import type { SidebarMenuNode } from '@/types/menu'
|
||||
|
||||
const LEAF: SidebarMenuNode = {
|
||||
key: 'approval_todo',
|
||||
title: '我的待办',
|
||||
path: '/approval/todo',
|
||||
icon: { template: '<i class="stub-icon" />' },
|
||||
}
|
||||
|
||||
function mountLeaf(collapsed: boolean) {
|
||||
return mount(SidebarMenuItem, {
|
||||
props: { item: LEAF, level: 0 },
|
||||
global: {
|
||||
plugins: [
|
||||
createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/approval/todo', component: { template: '<div />' } }],
|
||||
}),
|
||||
],
|
||||
provide: {
|
||||
'sidebar-menu': {
|
||||
collapsed: ref(collapsed),
|
||||
isLeafActive: () => false,
|
||||
isGroupActive: () => false,
|
||||
isGroupOpen: () => false,
|
||||
toggleGroup: () => {},
|
||||
selectPath: () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('SidebarMenuItem 叶子项', () => {
|
||||
it('展开态:图标与名称都在,不需要 title 提示', () => {
|
||||
const wrapper = mountLeaf(false)
|
||||
|
||||
expect(wrapper.find('.stub-icon').exists()).toBe(true)
|
||||
expect(wrapper.find('.layout-sidebar__label').text()).toBe('我的待办')
|
||||
expect(wrapper.find('a').attributes('title')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('折叠态:只渲染图标,名称不进 DOM,改为 title 提示', () => {
|
||||
const wrapper = mountLeaf(true)
|
||||
|
||||
expect(wrapper.find('.stub-icon').exists()).toBe(true)
|
||||
expect(wrapper.find('.layout-sidebar__label').exists()).toBe(false)
|
||||
expect(wrapper.find('a').attributes('title')).toBe('我的待办')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 自动编码面板(s_autocode):惰性建行 + 实时预览 + 校验。
|
||||
*
|
||||
* 关键点:模块从未配置自动编码时草稿保持空数组(打开面板即建行会把「无变更」
|
||||
* diff 成一条 insert),只有用户首次操作才落行;且 defineModel 写入后不能立刻
|
||||
* 回读 prop(要下一轮才回传),所以建行后返回的是 reactive 代理行。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { G3Switch } from '@g3soft/ui'
|
||||
import ModuleAutoCodePanel from '@/views/module/module-management/ModuleAutoCodePanel.vue'
|
||||
import type { ModuleAutoCodeRow } from '@/views/module/module-management/utils'
|
||||
|
||||
const FIELDS = [
|
||||
{ b_field: 'b_id', b_name: '编码', b_type: 'input', b_canuse: 1 },
|
||||
{ b_field: 'b_no', b_name: '业务单号', b_type: 'input', b_canuse: 1 },
|
||||
{ b_field: 'b_bz', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
|
||||
{ b_field: 'b_amount', b_name: '金额', b_type: 'money', b_canuse: 1 },
|
||||
{ b_field: 'b_off', b_name: '停用字段', b_type: 'input', b_canuse: 0 },
|
||||
]
|
||||
|
||||
/** 造一行规则:只写关心的字段,其余取启用态的常用默认值 */
|
||||
function row(overrides: Partial<ModuleAutoCodeRow> = {}): ModuleAutoCodeRow {
|
||||
return {
|
||||
b_module_id: 'm1',
|
||||
b_field: 'b_no',
|
||||
b_prefix: '',
|
||||
b_dateformat: 'yyyyMM',
|
||||
b_separator: '-',
|
||||
b_seqwidth: 5,
|
||||
b_resettype: 'month',
|
||||
b_startvalue: 1,
|
||||
b_currentperiod: null,
|
||||
b_currentvalue: 0,
|
||||
b_canuse: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** 模拟父级 v-model:autocode:emit 回来要显式写回 prop,否则复现不了「立刻回读旧值」的问题 */
|
||||
function mountPanel(autocode: ModuleAutoCodeRow[] = []) {
|
||||
const wrapper = mount(ModuleAutoCodePanel, {
|
||||
props: {
|
||||
moduleId: 'm1',
|
||||
fields: FIELDS,
|
||||
autocode,
|
||||
'onUpdate:autocode': (value: ModuleAutoCodeRow[]) => wrapper.setProps({ autocode: value }),
|
||||
},
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('自动编码面板', () => {
|
||||
it('首次拨「启用」时惰性建行并写回 b_canuse(defineModel 不能写完立刻回读)', async () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
// 模块从未配置自动编码:草稿为空,此时拨开关会走 ensureRow 建行
|
||||
wrapper.findComponent(G3Switch).vm.$emit('update:modelValue', true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const emitted = wrapper.emitted('update:autocode')
|
||||
expect(emitted).toBeTruthy()
|
||||
const created = emitted![0][0] as ModuleAutoCodeRow[]
|
||||
expect(created[0].b_canuse).toBe(1)
|
||||
expect(created[0].b_module_id).toBe('m1')
|
||||
// 业务编号字段自动取第一个可用文本字段(排除主键 b_id、非文本类型与停用字段)
|
||||
expect(created[0].b_field).toBe('b_no')
|
||||
})
|
||||
|
||||
it('已有一行时就地改,不再新建也不重新 emit 整个数组', async () => {
|
||||
// 传进去的数组即父级持有的草稿:断言它被就地改写
|
||||
const draft = [row({ b_canuse: 0, b_prefix: 'A' })]
|
||||
const wrapper = mountPanel(draft)
|
||||
|
||||
wrapper.findComponent(G3Switch).vm.$emit('update:modelValue', true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// 已落草稿:直接改这一行的属性(父级 ref 是深度响应式,改得见)
|
||||
expect(wrapper.emitted('update:autocode')).toBeUndefined()
|
||||
expect(draft[0]?.b_canuse).toBe(1)
|
||||
expect(draft[0]?.b_prefix).toBe('A') // 沿用原行,未被占位行覆盖
|
||||
})
|
||||
|
||||
it('紧凑网格固定竖排且标签左对齐,不跟随全局横排配置', () => {
|
||||
const wrapper = mountPanel()
|
||||
expect(wrapper.find('form').classes()).toContain('g3-form--vertical')
|
||||
// 标签左对齐依赖 G3Form 的默认 labelAlign=left;竖排下若回到 right 会被推到整列右边缘
|
||||
expect(wrapper.findAll('.g3-form-item__label.is-label-right')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('预览按 前缀 + 日期 + 补位流水号 拼接', async () => {
|
||||
const wrapper = mountPanel([
|
||||
row({ b_prefix: 'G3', b_dateformat: '', b_seqwidth: 4, b_resettype: 'none' }),
|
||||
])
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const preview = wrapper.find('[data-field="b_preview"] input')
|
||||
expect(preview.attributes('readonly')).toBeDefined()
|
||||
expect((preview.element as HTMLInputElement).value).toBe('G3-0001')
|
||||
})
|
||||
|
||||
it('启用态下的合法规则校验通过', async () => {
|
||||
const wrapper = mountPanel([row()])
|
||||
|
||||
expect(await wrapper.vm.validate()).toEqual([])
|
||||
})
|
||||
|
||||
it('启用但未选业务编号字段时报错(错误挂在对应 FormItem 上)', async () => {
|
||||
const wrapper = mountPanel([row({ b_field: null })])
|
||||
|
||||
const errors = await wrapper.vm.validate()
|
||||
expect(errors.map((item) => item.name)).toContain('b_field')
|
||||
expect(errors.find((item) => item.name === 'b_field')?.errors[0]).toBe('请选择业务编号字段')
|
||||
})
|
||||
|
||||
it('位数超出 1-18 时报错', async () => {
|
||||
const wrapper = mountPanel([row({ b_seqwidth: 20 })])
|
||||
|
||||
const errors = await wrapper.vm.validate()
|
||||
expect(errors.find((item) => item.name === 'b_seqwidth')?.errors[0]).toBe(
|
||||
'流水号位数必须是 1 到 18 之间的整数',
|
||||
)
|
||||
})
|
||||
|
||||
it('未启用时不校验(占位行只用于展示)', async () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
expect(await wrapper.vm.validate()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -220,7 +220,7 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
|
||||
| `virtualX` | `boolean` | `true` | 横向虚拟滚动总开关(实际是否开启受列数门控) |
|
||||
| `rowHeight` | `number` | `30` | 行高,同时驱动虚拟滚动计算 |
|
||||
| `headerRowHeight` | `number` | `34` | 表头行高 |
|
||||
| `bordered` | `boolean` | `true` | 是否显示边框(透传给底座的 `bordered: true`)。边框由底座绘制,不再是自绘外框 + 圆角 |
|
||||
| `bordered` | `boolean` | `true` | 边框。`true` = 自绘圆角外框 + 行分隔线;`false` = 无外框,只留行分隔线(嵌进卡片 / 面板时用) |
|
||||
| `stripe` | `boolean` | `false` | 斑马纹(隔行底色)。**固定列会一起上条纹**(见「固定列」):库只把条纹画在 `tr` 上,冻结列的底色必须不透明,组件在它上面补一层同色内阴影接上 |
|
||||
| `fixedColShadow` | `boolean` | `false` | 固定列被滚动内容遮挡时画 10px 渐变阴影(见「固定列」) |
|
||||
| `colResizable` | `boolean \| object` | `true` | 列宽拖拽;未绑定 `v-model:columns` 时自动关闭。可用 `{ disabled: (col) => boolean }` 按列禁用 |
|
||||
@@ -356,10 +356,13 @@ watch(n, (v) => { rows.value = Array.from({ length: v }, (_, i) => makeRow(i)) }
|
||||
- **主题通过覆盖库的 CSS 变量实现**:库把 41 个主题值都做成了 `.stk-table` 作用域内的变量,组件把它们映射到 `--g3-*` 令牌(覆盖层在 `src/table/style/_overrides.scss`)。改变量而不是逐个选择器改样式,库升级时不容易失效。
|
||||
- **区域选择按需自注册**:库的单元格区域选择是全局注册的(`registerFeature(useAreaSelection)`),不注册则 `area-selection-change` 永不触发、行内编辑失效。组件库不拥有 app 启动流程,因此**首次挂载时自动注册一次**;需要显式控制时可调用导出的 `registerTableFeatures()`。
|
||||
- **`data-testid` 透传到内层**:组件 `inheritAttrs: false`,`$attrs` 落在内层 `StkTable` 上,所以写 E2E 时按类名 `.g3-table` 定位根节点。
|
||||
- **边框完全交给底座**:组件把 `bordered` 直接透传为底座的 `bordered: true`,**不再自绘外框与行分隔线**。因此:
|
||||
- 底座用逐格 `background-image` 渐变画线(不是 `border`),横竖线都来自 `--bg-border-*` 变量;
|
||||
- 受滚动条影响,**表格右侧与底部的边框会消失**——这是底座文档(`table/basic/bordered`)明确说明的行为,需要时自行补 CSS;
|
||||
- 没有圆角;`bordered: false` 时四边与格线全部消失。
|
||||
- **边框:外框自绘,格线仍用底座的渐变**。组件把 `bordered` 翻成底座的 `'v'` 档(只借它的竖线),再自己补外框:
|
||||
- **外框** → 外层容器上的真 `border` + 圆角令牌(`bordered: false` 时不描边);
|
||||
- **横线** → 把 `'v'` 档清零的 `--bg-border-bottom` 配方拿回来;
|
||||
- **竖线** → 底座原样,只抹掉最右列那条(外侧线归外框管)。
|
||||
为什么不全交给底座:它的外框是 `border-left` + 三段背景渐变拼的,**做不出圆角**,且其右侧 / 底部边框会因滚动条占位而消失(底座 `table/basic/bordered` 文档明确说明)。
|
||||
⚠️ 格线**不能**改写成真 `border`:行内编辑器被钉死在 `var(--row-height)`,单元格多出 1px 边线就会被顶高 1px,而行高是虚拟滚动 `translateY` 的计算基准。背景渐变不参与盒模型。
|
||||
颜色只有 `--g3-border-color` 一个入口,亮 / 暗主题自动跟随。
|
||||
- **`--th-bgc` 等表头/表尾底色**由覆盖层的变量映射提供,不通过行内 style 注入。
|
||||
- **行编辑态存在 `WeakMap` 里**:按行对象存,不给业务数据打标记;行被回收时状态自动释放。行与行之间切换编辑**不翻转** `editingActive`,避免整表列重建。
|
||||
- **编辑器无缝嵌入单元格**(`editorSeamless`):把 `G3Input` / `G3Select` 自带的 `padding` / `border` / `border-radius` / `background` 置零,只保留焦点、hover 与校验提示;编辑器的高度由组件显式钉成 `var(--row-height)`(不能被控件的 `min-height: 32px` 顶高,也不能靠 `height: 100%` —— td 的内容盒不等于行高),因此**编辑前后的行高完全一致**。焦点环画在外层 `td` 的 `inset` 阴影上:不参与盒模型计算,几何零影响。
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { G3TableColumn, TableRow } from '@g3soft/ui'
|
||||
|
||||
// 最小用法:给 columns 与 dataSource 两份数据即可。
|
||||
// - seq 默认开启,会在最前列注入序号列
|
||||
// - bordered 默认开启,边框由底座(stk-table-vue)绘制
|
||||
// - bordered 默认开启:圆角外框由组件自绘;横竖格线仍走底座的背景渐变
|
||||
// - 没写 width 的列会自动摊掉剩余宽度
|
||||
// - 本例额外开了 `stripe`(斑马纹,默认关):注意**序号列也带条纹** ——
|
||||
// 冻结列的底色必须不透明(否则横滚时被压住的内容会透字),
|
||||
|
||||
@@ -66,22 +66,26 @@ test.describe('G3Table 表格', () => {
|
||||
expect(innerBox!.height).toBeLessThanOrEqual(wrapBox!.height + 2)
|
||||
})
|
||||
|
||||
test('边框由库绘制:不再有自绘外框与圆角', async ({ page }) => {
|
||||
// 组件已改为把边框完全交给库(传 bordered: true),自己不再画外框 / 圆角。
|
||||
test('边框自绘:外框是根节点的圆角描边,库的外框被清掉', async ({ page }) => {
|
||||
// 边框设计(见 docs/table-design.md §3.2):外框与横线由组件自绘,纵向列线用库的
|
||||
// 渐变。组件把 `bordered` 翻成库的 'v' 档(见 G3Table.vue 的 `:bordered` 绑定)。
|
||||
// data-testid 透传给内层 StkTable(组件 inheritAttrs: false),所以按类名定位根节点。
|
||||
const root = page.locator('[data-testid="table-wrap"] .g3-table').first()
|
||||
await expect(root).toHaveClass(/is-bordered/)
|
||||
|
||||
// 自绘外框已删除:根节点不该再有 border / 圆角
|
||||
const radius = await root.evaluate((el) => getComputedStyle(el).borderRadius)
|
||||
expect(radius).toBe('0px')
|
||||
const borderWidth = await root.evaluate((el) => getComputedStyle(el).borderTopWidth)
|
||||
expect(borderWidth).toBe('0px')
|
||||
// 自绘外框:根节点上有 1px 描边 + 圆角(库的外框做不出圆角,这正是自绘的理由)
|
||||
const outer = await root.evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { width: s.borderTopWidth, radius: s.borderTopLeftRadius }
|
||||
})
|
||||
expect(outer.width).toBe('1px')
|
||||
expect(outer.radius).not.toBe('0px')
|
||||
|
||||
// 边框改由库的 .bordered 提供
|
||||
await expect(page.locator('[data-testid="table-wrap"] .stk-table').first()).toHaveClass(
|
||||
/bordered/,
|
||||
)
|
||||
// 库那层外框必须被抹掉:'v' 档是字符串,库会同时挂 .bordered 与 .bordered-v,
|
||||
// 前者的 `border-left` 若留着,就和自绘外框叠成双线
|
||||
const stk = page.locator('[data-testid="table-wrap"] .stk-table').first()
|
||||
await expect(stk).toHaveClass(/bordered-v/)
|
||||
expect(await stk.evaluate((el) => getComputedStyle(el).borderLeftWidth)).toBe('0px')
|
||||
})
|
||||
|
||||
test('表头显示序号列(seq 默认开启)', async ({ page }) => {
|
||||
@@ -633,22 +637,42 @@ test.describe('G3Table 布局:表头固定与分隔线', () => {
|
||||
expect(Math.abs(after!.y - before!.y)).toBeLessThan(2)
|
||||
})
|
||||
|
||||
test('边框交由库绘制:库根节点带 .bordered,且不再叠加自绘实线', async ({ page }) => {
|
||||
test('边框自绘:库只保留竖线,横线走渐变而不是真 border', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 我们传 bordered: true,库会给根节点挂 .bordered(并因是字符串才挂 bordered-xxx)
|
||||
// 我们传的是库的 'v' 档(字符串才挂 bordered-xxx)
|
||||
const stk = wrap.locator('.stk-table').first()
|
||||
await expect(stk).toHaveClass(/bordered/)
|
||||
await expect(stk).toHaveClass(/bordered-v/)
|
||||
|
||||
// 自绘的行分隔线已删除,单元格不应再有真的 border-bottom
|
||||
// (库用 background-image 渐变画线,不走 border)
|
||||
const td = await wrap
|
||||
.locator('.stk-tbody-main td')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).borderBottomWidth)
|
||||
expect(td).toBe('0px')
|
||||
/** 数「非透明的背景渐变层数」——即这一格画了几条线(不看库内部怎么拼) */
|
||||
const lineCount = (locator: import('@playwright/test').Locator) =>
|
||||
locator.evaluate((el) =>
|
||||
getComputedStyle(el)
|
||||
.backgroundImage.split(/,(?![^(]*\))/)
|
||||
.filter((layer) => /rgb\(\d+, \d+, \d+\)/.test(layer)).length,
|
||||
)
|
||||
|
||||
// 横线:`'v'` 档把 --bg-border-bottom 清零(只剩竖线一条),组件按底座配方把它
|
||||
// 拿回来 —— 所以这里应当数出**两条**。但它**不能**是真 border:格线一进盒模型,
|
||||
// 单元格多 1px 就会被行内编辑器顶高(行高是虚拟滚动 translateY 的基准)。
|
||||
const td = wrap.locator('.stk-tbody-main td').first()
|
||||
const normalLines = await lineCount(td)
|
||||
expect(normalLines).toBeGreaterThan(1)
|
||||
expect(await td.evaluate((el) => getComputedStyle(el).borderBottomWidth)).toBe('0px')
|
||||
|
||||
// 竖线:末列的外侧那条已被抹掉(归自绘外框管),所以比普通列**正好少一条**
|
||||
const lastTd = wrap.locator('.stk-tbody-main td:last-child').first()
|
||||
expect(await lineCount(lastTd)).toBe(normalLines - 1)
|
||||
|
||||
// 行高没被格线动过(30 = 默认 rowHeight,也是虚拟滚动 translateY 的基准)
|
||||
expect(
|
||||
await wrap
|
||||
.locator('.stk-tbody-main tr')
|
||||
.first()
|
||||
.evaluate((el) => Math.round(el.getBoundingClientRect().height)),
|
||||
).toBe(30)
|
||||
})
|
||||
|
||||
test('库自绘边框:单元格带可见的横竖渐变线', async ({ page }) => {
|
||||
|
||||
@@ -39,7 +39,9 @@ const { b, m, is } = useNamespace('form')
|
||||
const merged = useComponentDefaults('form', props)
|
||||
|
||||
const resolvedLayout = computed(() => merged.value.layout ?? 'horizontal')
|
||||
const resolvedLabelAlign = computed(() => merged.value.labelAlign ?? 'right')
|
||||
// 默认左对齐:横排下右对齐需要定宽 label 才有意义,未给定宽时右对齐会让标签贴到控件一侧;
|
||||
// 竖排(layout="vertical")下 label 占满整列,右对齐更是直接把文字推到边缘。要右对齐请显式传。
|
||||
const resolvedLabelAlign = computed(() => merged.value.labelAlign ?? 'left')
|
||||
const resolvedLabelWidth = computed(() => merged.value.labelWidth)
|
||||
const resolvedValidateTrigger = computed(() => merged.value.validateTrigger ?? 'change')
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import G3Form from './Form.vue'
|
||||
import G3FormItem from './FormItem.vue'
|
||||
import G3FormGroup from './FormGroup.vue'
|
||||
|
||||
/**
|
||||
* 分组只是分区标题 + 一组共用列数 / 布局 / 间距的字段,**没有折叠能力**:
|
||||
* 标题不带箭头、不响应点击,组件也不该再声明折叠相关的 prop。
|
||||
*/
|
||||
const COMPONENTS = { G3Form, G3FormGroup, G3FormItem }
|
||||
|
||||
/** extraAttrs 走模板字符串,贴近真实写法 */
|
||||
function mountGroup(extraAttrs = '') {
|
||||
return mount({
|
||||
components: COMPONENTS,
|
||||
data() {
|
||||
return { model: { a: '' } }
|
||||
},
|
||||
template: `
|
||||
<G3Form :model="model">
|
||||
<G3FormGroup title="分组" ${extraAttrs}>
|
||||
<G3FormItem name="a" label="字段"><input /></G3FormItem>
|
||||
</G3FormGroup>
|
||||
</G3Form>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
describe('FormGroup', () => {
|
||||
it('标题只是分区标题,没有折叠入口', () => {
|
||||
const wrapper = mountGroup()
|
||||
|
||||
const title = wrapper.find('.g3-form-group__title')
|
||||
expect(title.text()).toBe('分组')
|
||||
expect(title.attributes('role')).toBeUndefined()
|
||||
expect(title.attributes('tabindex')).toBeUndefined()
|
||||
expect(title.attributes('aria-expanded')).toBeUndefined()
|
||||
expect(title.classes()).not.toContain('is-collapsible')
|
||||
// 箭头与折叠容器都不该存在
|
||||
expect(wrapper.find('.g3-form-group__arrow').exists()).toBe(false)
|
||||
expect(wrapper.find('.g3-form-group__collapse').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('点击标题不会改变内容可见性', async () => {
|
||||
const wrapper = mountGroup()
|
||||
|
||||
await wrapper.find('.g3-form-group__title').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
// 字段内容直接挂在 body 下,行高不受任何折叠状态影响
|
||||
const body = wrapper.find('.g3-form-group__body')
|
||||
expect(body.find('[data-field="a"]').exists()).toBe(true)
|
||||
expect(body.attributes('style') ?? '').not.toContain('0fr')
|
||||
})
|
||||
|
||||
it('不再声明折叠相关 prop', () => {
|
||||
const declared = Object.keys(
|
||||
(G3FormGroup as unknown as { props?: Record<string, unknown> }).props ?? {},
|
||||
)
|
||||
expect(declared).not.toContain('collapsible')
|
||||
expect(declared).not.toContain('defaultExpanded')
|
||||
expect(declared).not.toContain('modelValue')
|
||||
})
|
||||
|
||||
it('仍然提供组内列数上下文(columns > 1 走网格)', () => {
|
||||
const wrapper = mountGroup(':columns="2"')
|
||||
expect(wrapper.find('.g3-form-group').classes()).toContain('g3-form-group--grid')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { computed, inject, provide } from 'vue'
|
||||
import { useComponentDefaults } from '../_composables/useComponentDefaults'
|
||||
import { useNamespace } from '../_composables/useNamespace'
|
||||
import { formGroupKey, formKey } from './context'
|
||||
@@ -11,15 +10,17 @@ import type { FormGroupProps } from './types'
|
||||
* 表单分组 —— 一组字段共用独立的列数 / 布局 / 间距,便于在长表单里做宫格嵌套。
|
||||
*
|
||||
* 组内覆盖优先于外层 Form:FormItem 的 label 宽度、布局与跨列数都会先看组上下文。
|
||||
* 标题行可点击折叠(collapsible 默认开启);折叠用 grid-template-rows 过渡而不是卸载,
|
||||
* 收起期间字段与校验状态全部保留(也可用 v-model 受控展开态)。
|
||||
*
|
||||
* 标题只是分区标题,**不提供折叠**:把字段藏到标题后面,配置类表单里更容易漏看,
|
||||
* 分组本身也起不到收起省空间的作用(字段数是固定的)。同理也不做 hover 反馈——
|
||||
* 不可点的东西不该看起来可点。
|
||||
*
|
||||
* 视觉保持「无色块、无粘顶」的中性分区:分层只交给外层 Form 的组间距(见 Form 的 gap),
|
||||
* 组件本身不画底色 / 描边 / 分隔线,避免分组风格与页面主题或设计器分组撞车。
|
||||
*/
|
||||
defineOptions({ name: 'G3FormGroup' })
|
||||
|
||||
const props = withDefaults(defineProps<FormGroupProps>(), {
|
||||
modelValue: undefined,
|
||||
collapsible: true,
|
||||
defaultExpanded: true,
|
||||
columns: undefined,
|
||||
layout: undefined,
|
||||
labelWidth: undefined,
|
||||
@@ -28,47 +29,11 @@ const props = withDefaults(defineProps<FormGroupProps>(), {
|
||||
title: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [expanded: boolean] }>()
|
||||
|
||||
const { b, e, m, is } = useNamespace('form-group')
|
||||
const { b, e, m } = useNamespace('form-group')
|
||||
const form = inject(formKey, null)
|
||||
|
||||
const merged = useComponentDefaults('form-group', props)
|
||||
|
||||
/** 展开态:受控优先,否则内部维护(初值取 defaultExpanded) */
|
||||
const innerExpanded = ref(props.defaultExpanded)
|
||||
const expanded = computed(() => props.modelValue ?? innerExpanded.value)
|
||||
|
||||
/**
|
||||
* 折叠动画进行中:只有「收起 / 动画中」需要给内容加 overflow: hidden
|
||||
* (隐藏收起后的内容、防止动画期间内容外溢)。
|
||||
* 展开且静止时放开裁切,否则输入框的聚焦光环 / 阴影会在分组边界被切掉。
|
||||
*/
|
||||
const animating = ref(false)
|
||||
let animatingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(expanded, (value, oldValue) => {
|
||||
if (value === oldValue) return
|
||||
animating.value = true
|
||||
if (animatingTimer) clearTimeout(animatingTimer)
|
||||
// 与下方 grid-template-rows 的 0.2s 过渡对齐,略留余量
|
||||
animatingTimer = setTimeout(() => {
|
||||
animating.value = false
|
||||
animatingTimer = null
|
||||
}, 220)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animatingTimer) clearTimeout(animatingTimer)
|
||||
})
|
||||
|
||||
function toggleCollapse() {
|
||||
if (!props.collapsible) return
|
||||
const next = !expanded.value
|
||||
if (props.modelValue === undefined) innerExpanded.value = next
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
/** 组内列数:显式 columns > 外层 Form.columns > 1 */
|
||||
const columns = computed(() => merged.value.columns ?? form?.columns.value ?? 1)
|
||||
|
||||
@@ -115,29 +80,14 @@ provide(formGroupKey, {
|
||||
|
||||
<template>
|
||||
<div :class="[b(), { [m('grid')]: columns > 1 }]" :style="rootStyle">
|
||||
<div
|
||||
v-if="title || $slots.title"
|
||||
:class="[e('title'), { [is('collapsible')]: collapsible, [is('expanded')]: expanded }]"
|
||||
:role="collapsible ? 'button' : undefined"
|
||||
:tabindex="collapsible ? 0 : undefined"
|
||||
:aria-expanded="collapsible ? expanded : undefined"
|
||||
@click="toggleCollapse"
|
||||
@keydown.enter.prevent="toggleCollapse"
|
||||
@keydown.space.prevent="toggleCollapse"
|
||||
>
|
||||
<ChevronDown v-if="collapsible" :class="e('arrow')" aria-hidden="true" />
|
||||
<div v-if="title || $slots.title" :class="e('title')">
|
||||
<span :class="e('title-text')">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="[e('collapse'), { [is('collapsed')]: !expanded, [is('collapsing')]: animating }]"
|
||||
:style="{ gridTemplateRows: expanded ? '1fr' : '0fr' }"
|
||||
>
|
||||
<div :class="e('body')" :style="groupStyle">
|
||||
<slot />
|
||||
</div>
|
||||
<div :class="e('body')" :style="groupStyle">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -162,56 +112,6 @@ provide(formGroupKey, {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.#{e('form-group', 'title')}.#{is('collapsible')} {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.#{e('form-group', 'title')}.#{is('collapsible')}:hover .#{e('form-group', 'title-text')},
|
||||
.#{e('form-group', 'title')}.#{is('collapsible')}:hover .#{e('form-group', 'arrow')} {
|
||||
color: v('color-primary', #18181b);
|
||||
}
|
||||
|
||||
.#{e('form-group', 'title')}.#{is('collapsible')}:focus-visible {
|
||||
@include focus-ring();
|
||||
border-radius: v('radius-sm', 4px);
|
||||
}
|
||||
|
||||
.#{e('form-group', 'arrow')} {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: v('text-3', rgba(0, 0, 0, 0.45));
|
||||
/* 收起时箭头转向右侧,展开朝下 */
|
||||
transform: rotate(-90deg);
|
||||
transition:
|
||||
transform v('duration', 0.2s) v('ease', ease),
|
||||
color v('duration', 0.2s) v('ease', ease);
|
||||
}
|
||||
|
||||
.#{e('form-group', 'title')}.#{is('expanded')} .#{e('form-group', 'arrow')} {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
/* 折叠用网格行过渡:0fr 收起但内容不卸载,字段状态与校验态都还在 */
|
||||
.#{e('form-group', 'collapse')} {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows v('duration', 0.2s) v('ease', ease);
|
||||
|
||||
> * {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 裁切只在「收起 / 折叠动画中」开启:收起态内容不可见、动画期间防外溢;
|
||||
* 展开且静止时必须放开——常开 overflow: hidden 会把输入框的聚焦光环 / 阴影
|
||||
* 在分组边界(最后一行的下缘等)整条切掉。 */
|
||||
.#{e('form-group', 'collapse')}.#{is('collapsed')} > *,
|
||||
.#{e('form-group', 'collapse')}.#{is('collapsing')} > * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.#{e('form-group', 'body')} {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -156,3 +156,34 @@ describe('FormItem 校验与提示', () => {
|
||||
expect(wrapper.find('.g3-form-item__help').text()).toBe('名称不能为空')
|
||||
})
|
||||
})
|
||||
|
||||
describe('label 对齐默认值', () => {
|
||||
function mountWithLabelAlign(extra = '') {
|
||||
return mount({
|
||||
components: COMPONENTS,
|
||||
data() {
|
||||
return { model: { a: '' } }
|
||||
},
|
||||
template: `
|
||||
<G3Form :model="model" :label-width="80" ${extra}>
|
||||
<G3FormItem name="a" label="名称"><span class="plain" /></G3FormItem>
|
||||
</G3Form>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
it('未传 labelAlign 时默认左对齐', () => {
|
||||
const wrapper = mountWithLabelAlign()
|
||||
expect(wrapper.find('.g3-form-item__label').classes()).not.toContain('is-label-right')
|
||||
})
|
||||
|
||||
it('显式 label-align="right" 时才右对齐', () => {
|
||||
const wrapper = mountWithLabelAlign('label-align="right"')
|
||||
expect(wrapper.find('.g3-form-item__label').classes()).toContain('is-label-right')
|
||||
})
|
||||
|
||||
it('竖排下标签左对齐(不被默认值推到整列右边缘)', () => {
|
||||
const wrapper = mountWithLabelAlign('layout="vertical"')
|
||||
expect(wrapper.find('.g3-form-item__label').classes()).not.toContain('is-label-right')
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,7 @@ export interface FormProps {
|
||||
rules?: Record<string, FormRule[]>
|
||||
/** 布局;不传时回落全局默认值 */
|
||||
layout?: FormLayout
|
||||
/** label 文本对齐方式;不传时回落全局默认值 */
|
||||
/** label 文本对齐方式;不传时回落全局默认值,最终兜底为 left */
|
||||
labelAlign?: FormLabelAlign
|
||||
/** label 宽度(仅横向布局生效) */
|
||||
labelWidth?: string | number
|
||||
@@ -96,12 +96,6 @@ export interface FormItemProps {
|
||||
}
|
||||
|
||||
export interface FormGroupProps {
|
||||
/** 受控展开态(v-model);不传时由 defaultExpanded 决定 */
|
||||
modelValue?: boolean
|
||||
/** 是否可折叠:点击标题行切换展开 / 收起 */
|
||||
collapsible?: boolean
|
||||
/** 非受控时的初始展开态 */
|
||||
defaultExpanded?: boolean
|
||||
/**
|
||||
* 组内列数;不传时继承外层 Form 的 columns。
|
||||
* 传 1 可让分组的字段强制单列排布。
|
||||
|
||||
@@ -83,7 +83,9 @@ function toggle(event: MouseEvent) {
|
||||
width: v('switch-width', 32px);
|
||||
height: v('switch-height', 18px);
|
||||
border-radius: 999px;
|
||||
background: v('fill-hover', rgba(0, 0, 0, 0.08));
|
||||
/* 关闭态:中性填充 fill(0.04)。Semi 用 fill-0 打底、hover fill-1、按下 fill-2,
|
||||
我们用 fill / fill-hover 两档就近映射(见下方 hover / active) */
|
||||
background: v('fill', rgba(0, 0, 0, 0.04));
|
||||
transition: background-color v('duration', 0.2s) v('ease', ease);
|
||||
}
|
||||
|
||||
@@ -96,14 +98,46 @@ function toggle(event: MouseEvent) {
|
||||
height: v('switch-thumb-size', 14px);
|
||||
border-radius: 50%;
|
||||
background: v('bg-container', #fff);
|
||||
box-shadow: v('shadow', 0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
transition: transform v('duration', 0.2s) v('ease', ease);
|
||||
/* 立体感来自滑块自身,而不是轨道 —— Semi 的 track 完全没有 box-shadow,
|
||||
全靠这一层把滑块「托」起来。两层叠加:
|
||||
· 0 4px 6px rgba(0,0,0,.1) 投影,制造悬浮高度
|
||||
· 0 0 1px rgba(0,0,0,.3) 1px 贴边描边,白滑块落在浅色轨道上也有清晰边界
|
||||
只留投影会「发虚」,只留描边会「发平」,两层缺一不可。 */
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.1),
|
||||
0 0 1px rgba(0, 0, 0, 0.3);
|
||||
/* width 也要过渡:按下态滑块会延展,只过渡 transform 会「跳宽」 */
|
||||
transition:
|
||||
transform v('duration', 0.2s) v('ease', ease),
|
||||
width v('duration', 0.2s) v('ease', ease);
|
||||
}
|
||||
|
||||
/* ---------- 关闭态:hover / 按下 ---------- */
|
||||
.#{b('switch')}:hover:not(.#{is('disabled')}):not(.#{is('checked')}) .#{e('switch', 'track')} {
|
||||
background: v('fill-hover', rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
/* 关闭态按下:比 hover 再深一档。Semi 有 fill-2,我们只有 fill / fill-hover 两档。
|
||||
刻意**不用** box-shadow 叠色:track 的禁用态描边也用 box-shadow,
|
||||
两者会互相覆盖(选中/禁用的组合下直接丢失描边)。用 filter 压暗,与阴影互不干扰。 */
|
||||
.#{b('switch')}:active:not(.#{is('disabled')}):not(.#{is('checked')}) .#{e('switch', 'track')} {
|
||||
background: v('fill-hover', rgba(0, 0, 0, 0.08));
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
/* ---------- 开启态 ---------- */
|
||||
.#{b('switch')}.#{is('checked')} .#{e('switch', 'track')} {
|
||||
background: v('color-primary', #18181b);
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('checked')}:hover:not(.#{is('disabled')}) .#{e('switch', 'track')} {
|
||||
background: v('color-primary-hover', rgba(24, 24, 27, 0.9));
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('checked')}:active:not(.#{is('disabled')}) .#{e('switch', 'track')} {
|
||||
background: v('color-primary-active', rgba(24, 24, 27, 0.8));
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('checked')} .#{e('switch', 'thumb')} {
|
||||
/* 乘 --g3-dir-sign(LTR 1 / RTL -1):RTL 下选中态滑块向左移动 */
|
||||
transform: translateX(
|
||||
@@ -116,13 +150,59 @@ function toggle(event: MouseEvent) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 按下滑块延展(Semi 手感的关键)----------
|
||||
按下时滑块朝「要去的方向」拉长 4px,松开回弹。两态都要做:
|
||||
· 关闭态按下:滑块右缘延伸,暗示「将要往右走」;
|
||||
· 开启态按下:滑块左缘延伸 —— 位移同步回退 4px 抵掉,
|
||||
否则会把右边缘顶出轨道外。
|
||||
用 inset-inline-start 打底 + 逻辑方向符号,RTL 下自动跟随。 */
|
||||
.#{b('switch')}:active:not(.#{is('disabled')}) .#{e('switch', 'thumb')} {
|
||||
width: calc(#{v('switch-thumb-size', 14px)} + 4px);
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('checked')}:active:not(.#{is('disabled')}) .#{e('switch', 'thumb')} {
|
||||
transform: translateX(
|
||||
calc(
|
||||
#{v('dir-sign', 1)} * (
|
||||
#{v('switch-width', 32px)} - #{v('switch-thumb-size', 14px)} - #{v('switch-thumb-offset', 2px)} *
|
||||
2 - 4px
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.#{b('switch')}:focus-visible .#{e('switch', 'track')} {
|
||||
@include focus-ring();
|
||||
}
|
||||
|
||||
/* ---------- 禁用态 ----------
|
||||
关闭态禁用:Semi 是「透明底 + border 描边」($color-switch_disabled-border-default
|
||||
= --semi-color-border),比单纯压透明度更能表达「这里有个开关,但它是关的且不可用」。
|
||||
开启态禁用:保留主色底 + opacity,换灰会丢掉「它开着」这一信息。
|
||||
两态的滑块都去掉投影(禁用不该有「浮起」暗示),关闭态补描边保证白滑块有边界。 */
|
||||
.#{b('switch')}.#{is('disabled')} {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
color: v('text-disabled', rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('disabled')} .#{e('switch', 'track')} {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px v('border-color', #d9d9d9);
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('disabled')}.#{is('checked')} .#{e('switch', 'track')} {
|
||||
background: v('color-primary', #18181b);
|
||||
box-shadow: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('disabled')} .#{e('switch', 'thumb')} {
|
||||
box-shadow: inset 0 0 0 1px v('border-color', #d9d9d9);
|
||||
}
|
||||
|
||||
.#{b('switch')}.#{is('disabled')}.#{is('checked')} .#{e('switch', 'thumb')} {
|
||||
box-shadow: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -98,6 +98,17 @@ const props = withDefaults(
|
||||
virtualX?: boolean
|
||||
rowHeight?: number
|
||||
headerRowHeight?: number
|
||||
/**
|
||||
* 边框:`true`(默认)= 圆角外框 + 行分隔线;`false` = 无外框、只留行分隔线
|
||||
* (嵌进卡片 / 面板时用)。
|
||||
*
|
||||
* 不直接透传给底座(设计见 `docs/table-design.md` §3.2):底座的外框由
|
||||
* `border-left` + 三段背景渐变拼成,**做不出圆角**,且其右侧 / 底部边框会被
|
||||
* 滚动条占位吃掉。所以 `true` 会翻成底座的 `'v'` 档(只借它的竖线),**外框**
|
||||
* 改由组件用真 `border` + 圆角令牌画;**横线**则把底座清零的那条渐变配方拿回来
|
||||
* —— 格线为什么不一起用真 `border`,见 `style/_overrides.scss` 的「边框」一节
|
||||
* (真边线会把行撑高 1px,而行高是虚拟滚动的计算基准)。
|
||||
*/
|
||||
bordered?: boolean | string
|
||||
/**
|
||||
* 斑马纹(隔行底色),默认 `false`;等价于给底座的 `stripe` 传 true。
|
||||
@@ -1569,7 +1580,7 @@ const fixedPatchOff = (() => {
|
||||
:show-overflow="showOverflow"
|
||||
:empty-cell-text="''"
|
||||
:area-selection="editable"
|
||||
:bordered="bordered ? true : false"
|
||||
:bordered="bordered ? 'v' : false"
|
||||
@update:columns="onColumnsUpdate"
|
||||
@area-selection-change="handleAreaSelectionChange"
|
||||
@row-order-change="handleRowOrderChange"
|
||||
@@ -1599,10 +1610,10 @@ const fixedPatchOff = (() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 边框完全交给库:传 `bordered: true`,外框 / 圆角 / 横竖线都由库自己画。
|
||||
库文档(table/basic/bordered)说明:出于滚动条影响,表格**右侧和底部的边框
|
||||
由单元格的 border-right / border-bottom 实现,因此会消失**,需要时自行补 CSS。
|
||||
我们按库的原样使用,不再自绘外框与行分隔线。 */
|
||||
/* 边框:`bordered` 只翻译成底座的档位(true → 'v',false → false),真正的
|
||||
圆角外框与「横线怎么补回来」都在 `style/_overrides.scss` 的「边框」一节 ——
|
||||
为什么外框不再交给底座(做不出圆角、右侧底部边框被滚动条吃掉)、为什么格线
|
||||
不能用真 border(会把行撑高 1px),都写在那一节,别在这里重复。 */
|
||||
|
||||
.#{e('table', 'inner')} {
|
||||
width: 100%;
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
//
|
||||
// 为什么改变量而不是改选择器:
|
||||
// 库把 41 个主题值全部做成了 `.stk-table` 作用域内的 CSS 变量(实测见 style.css)。
|
||||
// 覆盖变量既能一次改全套(边框、hover、斑马纹、排序箭头、滚动条…),
|
||||
// 又不会因为库内部 DOM 结构调整而失效。fms 的做法是逐个选择器覆写,
|
||||
// 结果同一个「边框颜色」散落在 3 处(自己画外框 + 清库的渐变 + 补行分隔线),
|
||||
// 库一升级就要重新验证每一条。这里改成一个入口。
|
||||
// 覆盖变量既能一次改全套(hover、斑马纹、排序箭头、滚动条…),
|
||||
// 又不会因为库内部 DOM 结构调整而失效。
|
||||
//
|
||||
// ⚠ 唯一例外是**边框**(外框 + 横线):那部分由组件自绘,不走变量路线,
|
||||
// 见下方「边框」一节。底座的边框画法做不出圆角,也不是「改一个变量」能解决的
|
||||
// ——它更像结构问题,硬套变量映射只会把改动散到四处。
|
||||
//
|
||||
// 所有令牌都带 fallback:未加载 @g3soft/tokens 时组件仍可正常渲染。
|
||||
@use '@g3soft/tokens/scss' as *;
|
||||
@@ -134,6 +136,97 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 边框:外框圆角描边 + 行分隔线由组件自绘,纵向列线仍借底座的渐变
|
||||
// --------------------------------------------------------------------------
|
||||
// 底座不用 `border` 画线,而是把四条边做成 `background-image` 渐变
|
||||
// (--bg-border-*,由 --border-color 派生),并给 `bordered` 提供三档:
|
||||
// true=外框+横竖线 / 'h'=只要横线 / 'v'=只要竖线 / false=全不要。
|
||||
// 这套画法有两处绕不过去:
|
||||
// · 外框是「`border-left` + 三段背景渐变」拼出来的,**做不出圆角**;且底座文档
|
||||
// (table/basic/bordered)写明:受滚动条占位影响,表格**右侧与底部的边框会
|
||||
// 消失**,需要时自行补 CSS;
|
||||
// · 越往后越多边线要单独摘掉(最右列外侧线该归外框管,右固定列的左侧线又必须
|
||||
// 留着),而它们共用 `--border-color` 一个派生入口 —— 只能去覆盖渐变本身。
|
||||
//
|
||||
// 于是这里定成:**外框改用真 border 自绘,所有格线(横线 + 竖线)都保持底座的
|
||||
// 背景渐变画法**。
|
||||
// 外框 → `.g3-table.is-bordered` 上的真 `border` + 圆角令牌(外层容器已有
|
||||
// `overflow: hidden`,圆角能把内部内容裁掉);
|
||||
// 横线 → 把 `bordered-v` 清零的 --bg-border-bottom **恢复成底座那份配方**;
|
||||
// 竖线 → 保留底座的 --bg-border-right 渐变,只抹掉最右列那一条。
|
||||
//
|
||||
// 组件侧只负责把 `bordered` 翻成 `true` / `'v'` / `false`(见 G3Table.vue 的
|
||||
// `:bordered` 绑定)——`'v'` 必然同时带上 `.bordered` 与 `.bordered-v` 两个类
|
||||
// (底座 `{[`bordered-${bordered}`]: typeof bordered === 'string'}`),
|
||||
// 所以下面既要清掉 `.bordered` 画的外框,也要接受 `.bordered-v` 关掉的上下渐变
|
||||
// (只把横线那条拿回来,`--bg-border-top` 继续透明,免得与自绘外框叠成双线)。
|
||||
//
|
||||
// ⚠⚠ 格线**绝对不能**改成真 `border-bottom` / `border-top`(这是第一版踩的坑,
|
||||
// e2e `行内编辑 › 进入编辑:行高不变` 当场变红):行内编辑器被钉死在
|
||||
// `var(--row-height)`(30px,见 G3Table.vue 的 editorSeamless 区块),而 td 的
|
||||
// 高度是「按内容撑开」的 —— 一旦 td 多出 1px 边线,内容盒只剩 29px 放不下 30px
|
||||
// 的编辑器,整行被顶到 31px。行高是虚拟滚动 `translateY` 的计算基准,涨 1px
|
||||
// 会一路累积成行错位。**背景渐变不参与盒模型**,所以格线只能走 background-image。
|
||||
//
|
||||
// ⚠ 颜色统一走 `--g3-border-color` 令牌,亮 / 暗主题自动跟随,别再写死色值。
|
||||
// ==========================================================================
|
||||
|
||||
// 外框:只有 `bordered` 为真才有(false = 嵌进卡片,只留行分隔线)。
|
||||
// `box-sizing: border-box` 必须有:根节点默认是 content-box,加 1px 描边会让它
|
||||
// 比父容器高 2px(`:not(.is-bordered)` 时不描边,故不设)。
|
||||
.#{b('table')}.#{is('bordered')} {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #{v('border-color', #d9d9d9)};
|
||||
border-radius: v('radius-lg', 8px);
|
||||
}
|
||||
|
||||
.#{b('table')} {
|
||||
.#{e('table', 'inner')}.stk-table {
|
||||
// 横线:拿回 `bordered-v` 清零的那条。配方照抄底座(style.css 的
|
||||
// `--bg-border-bottom`),因为这是它的变量、也只能按它的语法写。
|
||||
//
|
||||
// 两条规则都挂在 `.bordered-v` 下:它只在真开了边框时才存在(见 G3Table.vue
|
||||
// 的 `:bordered`),所以 `bordered: false` 的无框模式不会被这里添线。
|
||||
&.bordered-v {
|
||||
--bg-border-bottom: linear-gradient(
|
||||
0deg,
|
||||
var(--border-color) var(--border-width),
|
||||
transparent var(--border-width)
|
||||
);
|
||||
|
||||
// 合计行:分隔线画在 footer 单元格的**上**边(数据区最后一行被 sticky 的
|
||||
// footer 压住,它那条横线指望不上)。同样用渐变,理由见上。
|
||||
.stk-footer td {
|
||||
background-image:
|
||||
var(--bg-border-right),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--border-color) var(--border-width),
|
||||
transparent var(--border-width)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去掉底座的外框:根节点上那条 `border-left` 与三段背景渐变(外框已由上面画)。
|
||||
.#{e('table', 'inner')}.stk-table.bordered-v {
|
||||
border-left: none;
|
||||
background-image: none;
|
||||
|
||||
// 最右列的外侧竖线同样归外框管,抹掉单元格自己那条(两条紧挨着会被看成 2px)。
|
||||
//
|
||||
// ⚠️ 只能改 --bg-border-right 这一个变量,**不能整体清空 background-image**:
|
||||
// 底座会给「被前面内容穿透遮挡」的右固定列补 `.fixed-cell--border-left`,
|
||||
// 用 --bg-border-left 画它左边的分隔线;整份清空会把这根线一起干掉
|
||||
// (症状:操作列压住内容却没有分割线)。
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
--bg-border-right: linear-gradient(transparent, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 表头 / 表尾:不透明底色(防 sticky 滚动时透出下方内容)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -225,7 +225,11 @@ export interface TableProps<T extends TableRow = TableRow> {
|
||||
rowHeight?: number
|
||||
/** 表头行高 */
|
||||
headerRowHeight?: number
|
||||
/** 边框:true=外框圆角描边+行分隔线;false=无外框仅行分隔线 */
|
||||
/**
|
||||
* 边框:`true`(默认)= 圆角外框 + 行分隔线;`false` = 无外框仅行分隔线。
|
||||
* 外框由本组件用真 `border` 自绘(底座的外框做不出圆角),格线仍走底座的背景
|
||||
* 渐变,见 `style/_overrides.scss` 的「边框」一节。
|
||||
*/
|
||||
bordered?: boolean | string
|
||||
/**
|
||||
* 斑马纹(隔行底色),默认 `false`。透传给底座的 `stripe`。
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('resolveSeqConfig', () => {
|
||||
|
||||
it('seq 为 true 时用默认标题与宽度', () => {
|
||||
const cfg = resolveSeqConfig(true)
|
||||
expect(cfg).toEqual({ enabled: true, title: '序号', width: 50 })
|
||||
expect(cfg).toEqual({ enabled: true, title: '序号', width: 60 })
|
||||
})
|
||||
|
||||
it('传对象时接受自定义标题与宽度', () => {
|
||||
@@ -40,7 +40,7 @@ describe('resolveSeqConfig', () => {
|
||||
})
|
||||
|
||||
it('只给 title 时宽度回落默认值', () => {
|
||||
expect(resolveSeqConfig({ title: '序号列' }).width).toBe(50)
|
||||
expect(resolveSeqConfig({ title: '序号列' }).width).toBe(60)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -36,12 +36,12 @@ export interface SeqConfig {
|
||||
export function resolveSeqConfig(
|
||||
seq: boolean | { title?: string; width?: number } | undefined,
|
||||
): SeqConfig {
|
||||
if (!seq) return { enabled: false, title: '序号', width: 50 }
|
||||
if (!seq) return { enabled: false, title: '序号', width: 60 }
|
||||
const cfg: { title?: string; width?: number } = seq === true ? { } : seq
|
||||
return {
|
||||
enabled: true,
|
||||
title: cfg.title ?? '序号',
|
||||
width: Number(cfg.width ?? 50),
|
||||
width: Number(cfg.width ?? 60),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -390,7 +390,10 @@ const VNodeRenderer = (renderProps: {
|
||||
gap: v('space-1', 4px);
|
||||
box-sizing: border-box;
|
||||
min-height: v('tabs-tab-height', 38px);
|
||||
padding: 0 v('space-3', 12px);
|
||||
/* 左右只留 4px:选中线是 border-bottom,宽度跟着盒子走。
|
||||
padding 一大,线就比文字宽出一圈(原来 12px → 线比文字宽 24px)。
|
||||
页签之间的间隔改由 margin-right 承担(见下),不再靠这里的 padding。 */
|
||||
padding: 0 v('space-1', 4px);
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
@@ -406,6 +409,21 @@ const VNodeRenderer = (renderProps: {
|
||||
border-color v('duration', 0.2s) v('ease', ease);
|
||||
}
|
||||
|
||||
/* 页签间距走 margin,把「间隔」与「选中线宽度」解耦:
|
||||
选中线依赖 padding,间隔依赖 margin,两者互不干扰。
|
||||
首个页签贴齐左边缘(Semi 的 nth-of-type(1) 处理),否则整排会右移 16px。
|
||||
横向用 margin-right(末尾多出的一截由 nav-list 的负 margin 抵消);
|
||||
垂直模式沿用 column 的 gap,不吃这套。 */
|
||||
.#{b('tabs')}:not(.#{m('tabs', 'left')}):not(.#{m('tabs', 'right')})
|
||||
.#{e('tabs', 'tab')}:not(:last-child) {
|
||||
margin-right: v('space-4', 16px);
|
||||
}
|
||||
|
||||
.#{b('tabs')}:not(.#{m('tabs', 'left')}):not(.#{m('tabs', 'right')}) .#{e('tabs', 'nav-list')} {
|
||||
/* 抵消最后一个页签多出的 margin-right,避免栏内右侧凭空多出 16px 空隙 */
|
||||
margin-right: calc(-1 * #{v('space-4', 16px)});
|
||||
}
|
||||
|
||||
.#{e('tabs', 'tab')}:hover:not(:disabled) {
|
||||
color: v('color-primary', #18181b);
|
||||
}
|
||||
@@ -419,6 +437,9 @@ const VNodeRenderer = (renderProps: {
|
||||
.#{b('tabs')}.#{m('tabs', 'right')} .#{e('tabs', 'tab')} {
|
||||
justify-content: flex-start;
|
||||
border-bottom: 0;
|
||||
/* 垂直模式的指示线在左/右侧(border-left/right),不吃底部的 padding,
|
||||
所以这里可以放心用常规内边距,让文字与竖线之间有呼吸感 */
|
||||
padding: 0 v('space-3', 12px);
|
||||
}
|
||||
|
||||
.#{b('tabs')}.#{m('tabs', 'left')} .#{e('tabs', 'tab')}.#{is('tab-active')} {
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Table 行编辑 · 单元格状态 · 边框变体</title>
|
||||
<style>
|
||||
/* ============================================================
|
||||
基础
|
||||
============================================================ */
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: #f5f5f5;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
h2 { font-size: 15px; margin: 0 0 12px; font-weight: 600; }
|
||||
.hint { color: rgba(0, 0, 0, 0.45); font-size: 12px; margin: 0 0 20px; }
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.card > .note {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
margin: -4px 0 12px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
表格骨架
|
||||
表头与单元格的内边距、行高、文字省略都在这里统一。
|
||||
============================================================ */
|
||||
.tbl {
|
||||
width: 100%;
|
||||
border-collapse: separate; /* separate 才能让每格的边框独立控制 */
|
||||
border-spacing: 0;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
table-layout: fixed; /* 定宽布局,列宽不随内容抖动 */
|
||||
}
|
||||
|
||||
.tbl th,
|
||||
.tbl td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tbl thead th {
|
||||
background: #f5f5f5;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tbl td.is-num,
|
||||
.tbl th.is-num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* 行操作列宽一点,放得下「编辑/保存/取消」 */
|
||||
.col-op { width: 132px; }
|
||||
.col-idx { width: 48px; text-align: center; }
|
||||
.col-name { width: 22%; }
|
||||
.col-amount { width: 15%; }
|
||||
.col-status { width: 15%; }
|
||||
.col-remark { width: auto; }
|
||||
|
||||
/* ============================================================
|
||||
★ 边框变体(本次重点)
|
||||
用 CSS 变量驱动,一份标记切换四种边框:
|
||||
--bw 线宽
|
||||
--bc 线色
|
||||
外框始终存在,差别只在「内部格线画哪些方向」
|
||||
============================================================ */
|
||||
.tbl {
|
||||
--bw: 1px;
|
||||
--bc: #d9d9d9;
|
||||
--outer: var(--bc);
|
||||
}
|
||||
|
||||
/* ---- 变体 1:全边框(横竖都有,含外框)---- */
|
||||
.tbl.bordered-all {
|
||||
border: var(--bw) solid var(--outer);
|
||||
}
|
||||
.tbl.bordered-all th,
|
||||
.tbl.bordered-all td {
|
||||
border-right: var(--bw) solid var(--bc);
|
||||
border-bottom: var(--bw) solid var(--bc);
|
||||
}
|
||||
/* 最后一列不画右边线,否则与外框叠成 2px */
|
||||
.tbl.bordered-all th:last-child,
|
||||
.tbl.bordered-all td:last-child { border-right: 0; }
|
||||
/* 最后一行不画下边线,同理 */
|
||||
.tbl.bordered-all tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ---- 变体 2:只有横向线(+外框)----
|
||||
行与行之间有横线,列与列之间没有竖线;最外面一圈仍在。
|
||||
表头下方的那条线由 th 的 border-bottom 提供,天然包含在内。 */
|
||||
.tbl.bordered-x {
|
||||
border: var(--bw) solid var(--outer);
|
||||
}
|
||||
.tbl.bordered-x th,
|
||||
.tbl.bordered-x td {
|
||||
border-bottom: var(--bw) solid var(--bc);
|
||||
border-right: 0;
|
||||
}
|
||||
.tbl.bordered-x tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ---- 变体 3:只有外框,内部无线 ---- */
|
||||
.tbl.bordered-outer {
|
||||
border: var(--bw) solid var(--outer);
|
||||
}
|
||||
.tbl.bordered-outer th,
|
||||
.tbl.bordered-outer td {
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
/* 内无横线时,表头需要一条线把自己与表体分开,否则糊成一片 */
|
||||
.tbl.bordered-outer thead th {
|
||||
border-bottom: var(--bw) solid var(--bc);
|
||||
}
|
||||
|
||||
/* ---- 变体 4:只有竖线(+外框),横向无线 ---- */
|
||||
.tbl.bordered-y {
|
||||
border: var(--bw) solid var(--outer);
|
||||
}
|
||||
.tbl.bordered-y th,
|
||||
.tbl.bordered-y td {
|
||||
border-right: var(--bw) solid var(--bc);
|
||||
border-bottom: 0;
|
||||
}
|
||||
.tbl.bordered-y th:last-child,
|
||||
.tbl.bordered-y td:last-child { border-right: 0; }
|
||||
.tbl.bordered-y thead th {
|
||||
border-bottom: var(--bw) solid var(--bc);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
★ 单元格状态
|
||||
与组件库同一套语义色:
|
||||
error → color-danger #ec4246,8% 混入底色
|
||||
warning → color-warning #e79a0d,5% 混入底色
|
||||
disabled → 灰底 + 文字降一档亮度(is-readonly)
|
||||
底色**不透明**:半透明会让行 hover 透上来,分不清「格有问题」还是「行被选中」。
|
||||
============================================================ */
|
||||
.tbl td.is-error {
|
||||
background-color: color-mix(in srgb, #ec4246 8%, #fff);
|
||||
}
|
||||
.tbl td.is-warning {
|
||||
background-color: color-mix(in srgb, #e79a0d 5%, #fff);
|
||||
}
|
||||
.tbl td.is-disabled {
|
||||
background-color: color-mix(in srgb, rgba(0, 0, 0, 0.88) 3%, #fff);
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 校验格的编辑态让位:正在编辑时不铺底色,改用语义色焦点环 + 红字。
|
||||
判据用 .is-editing 标记,而不是 :focus-within —— select 展开时焦点会跑进
|
||||
浮层,:focus-within 会时有时无,环就会闪。 */
|
||||
.tbl td.is-error.is-editing,
|
||||
.tbl td.is-warning.is-editing {
|
||||
background-color: #fff;
|
||||
color: inherit;
|
||||
}
|
||||
.tbl td.is-error.is-editing {
|
||||
box-shadow:
|
||||
inset 0 0 0 2px #ec4246,
|
||||
inset 0 0 0 3px #fff;
|
||||
}
|
||||
.tbl td.is-warning.is-editing {
|
||||
box-shadow:
|
||||
inset 0 0 0 2px #e79a0d,
|
||||
inset 0 0 0 3px #fff;
|
||||
}
|
||||
|
||||
/* 普通编辑格:主色焦点环。
|
||||
与上面两条语义色环共存靠**权重**定胜负,不靠书写顺序:
|
||||
.tbl td.is-error.is-editing → (0,4,0)
|
||||
.tbl td.is-warning.is-editing → (0,4,0)
|
||||
.tbl td.is-editing → (0,3,0)
|
||||
前两条更高,所以校验格在编辑时仍然是红/橙环,不会被这条盖成主色。
|
||||
这里刻意**不写** box-shadow: inherit 去「提权」:inherit 取的是父元素
|
||||
(tr / table)的值,不是「上一条更具体的规则」,会把语义色环整个抹掉。 */
|
||||
.tbl td.is-editing {
|
||||
box-shadow:
|
||||
inset 0 0 0 2px #18181b,
|
||||
inset 0 0 0 3px #fff;
|
||||
}
|
||||
|
||||
/* 实时输入框:融进单元格,看起来就是「格子在编辑」 */
|
||||
.cell-input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.cell-input::placeholder { color: rgba(0, 0, 0, 0.25); }
|
||||
|
||||
/* 校验提示:贴在单元格下方,不占行高 */
|
||||
.cell-msg {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
margin-top: 2px;
|
||||
white-space: normal;
|
||||
}
|
||||
.is-error .cell-msg { color: #ec4246; }
|
||||
.is-warning .cell-msg { color: #e79a0d; }
|
||||
|
||||
/* 带提示的行需要放开 nowrap,否则提示会被省略号截掉 */
|
||||
.tbl td.has-msg { white-space: normal; }
|
||||
|
||||
/* ============================================================
|
||||
只读 / 禁用视图
|
||||
============================================================ */
|
||||
.ro {
|
||||
display: block;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
按钮
|
||||
============================================================ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { border-color: #18181b; }
|
||||
.btn-primary {
|
||||
background: #18181b;
|
||||
border-color: #18181b;
|
||||
color: #fafafa;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: rgba(24, 24, 27, 0.9); }
|
||||
.btn-link {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: #18181b;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.btn-link:hover:not(:disabled) { color: rgba(24, 24, 27, 0.8); }
|
||||
.btn-danger { color: #ec4246; }
|
||||
.btn:disabled,
|
||||
.btn.is-disabled {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
border-color: #d9d9d9;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary:disabled,
|
||||
.btn-primary.is-disabled {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: #d9d9d9;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.op-group { display: flex; gap: 4px; }
|
||||
|
||||
/* 行 hover:只染可编辑行,禁用行不动 */
|
||||
.tbl tbody tr:hover > td { background-color: rgba(0, 0, 0, 0.02); }
|
||||
.tbl tbody tr:hover > td.is-error { background-color: color-mix(in srgb, #ec4246 11%, #fff); }
|
||||
.tbl tbody tr:hover > td.is-warning { background-color: color-mix(in srgb, #e79a0d 8%, #fff); }
|
||||
.tbl tbody tr:hover > td.is-disabled {
|
||||
background-color: color-mix(in srgb, rgba(0, 0, 0, 0.88) 3%, #fff);
|
||||
}
|
||||
.tbl tbody tr:hover > td.is-editing { background-color: #fff; }
|
||||
|
||||
/* 序号列居中且用次级色 */
|
||||
.tbl .col-idx,
|
||||
.tbl td.col-idx { color: rgba(0, 0, 0, 0.45); text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Table 行编辑 · 单元格状态 · 边框变体</h1>
|
||||
<p class="hint">
|
||||
行内编辑:点「编辑」进入,可改单元格内容;金额为 error / warning 的格子会实时校验。
|
||||
边框变体共四种,外框始终保留,差别只在内部格线画哪些方向。
|
||||
</p>
|
||||
|
||||
<!-- ============================================================
|
||||
1. 行编辑 + 三种单元格状态
|
||||
============================================================ -->
|
||||
<div class="card">
|
||||
<h2>1. 行编辑 · error / warning / disabled 混合</h2>
|
||||
<p class="note">
|
||||
第 1 行可编辑;第 2 行整行禁用(灰底,操作列按钮禁用);金额格演示校验态。
|
||||
</p>
|
||||
<table class="tbl bordered-all" id="grid1">
|
||||
<colgroup>
|
||||
<col class="col-idx"><col class="col-name"><col class="col-amount">
|
||||
<col class="col-status"><col class="col-remark"><col class="col-op">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-idx">#</th>
|
||||
<th>名称</th>
|
||||
<th class="is-num">金额</th>
|
||||
<th>状态</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- 行 1:可编辑,金额 error(<=0),备注 warning(空) -->
|
||||
<tr data-row="1">
|
||||
<td class="col-idx">1</td>
|
||||
<td data-field="name"><span class="ro">合同 A</span></td>
|
||||
<td class="is-num is-error has-msg" data-field="amount" data-validate="positive">
|
||||
<span class="ro">0</span>
|
||||
<span class="cell-msg">金额必须大于 0</span>
|
||||
</td>
|
||||
<td data-field="status"><span class="ro">启用</span></td>
|
||||
<td class="is-warning has-msg" data-field="remark" data-validate="notEmpty">
|
||||
<span class="ro"></span>
|
||||
<span class="cell-msg">建议填写备注</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="op-group" data-op>
|
||||
<button class="btn btn-link" data-act="edit">编辑</button>
|
||||
<button class="btn btn-link btn-danger" data-act="del">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 行 2:整行禁用 -->
|
||||
<tr data-row="2" data-locked="1">
|
||||
<td class="col-idx">2</td>
|
||||
<td class="is-disabled" data-field="name"><span class="ro">合同 B(已归档)</span></td>
|
||||
<td class="is-num is-disabled" data-field="amount"><span class="ro">--</span></td>
|
||||
<td class="is-disabled" data-field="status"><span class="ro">停用</span></td>
|
||||
<td class="is-disabled" data-field="remark"><span class="ro">不可修改</span></td>
|
||||
<td>
|
||||
<div class="op-group" data-op>
|
||||
<button class="btn btn-link" disabled>编辑</button>
|
||||
<button class="btn btn-link" disabled>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 行 3:可编辑,全部合法 -->
|
||||
<tr data-row="3">
|
||||
<td class="col-idx">3</td>
|
||||
<td data-field="name"><span class="ro">合同 C</span></td>
|
||||
<td class="is-num" data-field="amount" data-validate="positive">
|
||||
<span class="ro">43000</span>
|
||||
</td>
|
||||
<td data-field="status"><span class="ro">启用</span></td>
|
||||
<td data-field="remark" data-validate="notEmpty"><span class="ro">待复核</span></td>
|
||||
<td>
|
||||
<div class="op-group" data-op>
|
||||
<button class="btn btn-link" data-act="edit">编辑</button>
|
||||
<button class="btn btn-link btn-danger" data-act="del">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
2. 状态总览(静态,便于横向比对四种状态的观感)
|
||||
============================================================ -->
|
||||
<div class="card">
|
||||
<h2>2. 单元格状态总览(静态)</h2>
|
||||
<p class="note">
|
||||
四种状态同屏对比:正常 / error / warning / disabled。
|
||||
</p>
|
||||
<table class="tbl bordered-all">
|
||||
<colgroup>
|
||||
<col style="width: 16%"><col style="width: 21%"><col style="width: 21%">
|
||||
<col style="width: 21%"><col>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>列</th><th>正常</th><th>error</th><th>warning</th><th>disabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>文本</td>
|
||||
<td>合同 A</td>
|
||||
<td class="is-error has-msg">合同 A<span class="cell-msg">名称重复</span></td>
|
||||
<td class="is-warning has-msg">合同 A<span class="cell-msg">建议改为全称</span></td>
|
||||
<td class="is-disabled"><span class="ro">合同 A</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>数字(右对齐)</td>
|
||||
<td class="is-num">12000</td>
|
||||
<td class="is-num is-error has-msg">0<span class="cell-msg">金额必须大于 0</span></td>
|
||||
<td class="is-num is-warning has-msg">999999<span class="cell-msg">超出建议范围</span></td>
|
||||
<td class="is-num is-disabled"><span class="ro">12000</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>编辑中</td>
|
||||
<td class="is-editing"><input class="cell-input" value="正在输入"></td>
|
||||
<td class="is-error is-editing"><input class="cell-input" value="0"></td>
|
||||
<td class="is-warning is-editing"><input class="cell-input" value="999999"></td>
|
||||
<td class="is-disabled"><span class="ro">不可编辑</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
3. 边框变体
|
||||
============================================================ -->
|
||||
<div class="card">
|
||||
<h2>3. 边框变体:全边框</h2>
|
||||
<p class="note">横竖格线齐全,最外一圈与内部线宽一致。末行/末列不重复描边,避免叠成 2px。</p>
|
||||
<table class="tbl bordered-all">
|
||||
<thead>
|
||||
<tr><th class="col-idx">#</th><th>名称</th><th class="is-num">金额</th><th>状态</th><th>备注</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td class="col-idx">1</td><td>合同 A</td><td class="is-num">12000</td><td>启用</td><td>已归档</td></tr>
|
||||
<tr><td class="col-idx">2</td><td>合同 B</td><td class="is-num is-error has-msg">0<span class="cell-msg">金额必须大于 0</span></td><td>停用</td><td></td></tr>
|
||||
<tr><td class="col-idx">3</td><td>合同 C</td><td class="is-num">43000</td><td>启用</td><td>待复核</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>4. 边框变体:只有横向线(+ 外框)</h2>
|
||||
<p class="note">
|
||||
行与行之间有横线,列与列之间没有竖线;最外一圈边框仍然完整保留。
|
||||
表头下方那条线也在,用来把表头与表体分开。
|
||||
</p>
|
||||
<table class="tbl bordered-x">
|
||||
<thead>
|
||||
<tr><th class="col-idx">#</th><th>名称</th><th class="is-num">金额</th><th>状态</th><th>备注</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td class="col-idx">1</td><td>合同 A</td><td class="is-num">12000</td><td>启用</td><td>已归档</td></tr>
|
||||
<tr><td class="col-idx">2</td><td>合同 B</td><td class="is-num is-warning has-msg">999999<span class="cell-msg">超出建议范围</span></td><td>停用</td><td></td></tr>
|
||||
<tr><td class="col-idx">3</td><td>合同 C</td><td class="is-num">43000</td><td>启用</td><td>待复核</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>5. 边框变体:只有外框</h2>
|
||||
<p class="note">内部完全无线,只有最外一圈;表头下方保留一条分隔线。</p>
|
||||
<table class="tbl bordered-outer">
|
||||
<thead>
|
||||
<tr><th class="col-idx">#</th><th>名称</th><th class="is-num">金额</th><th>状态</th><th>备注</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td class="col-idx">1</td><td>合同 A</td><td class="is-num">12000</td><td>启用</td><td>已归档</td></tr>
|
||||
<tr><td class="col-idx">2</td><td>合同 B</td><td class="is-num is-error has-msg">0<span class="cell-msg">金额必须大于 0</span></td><td>停用</td><td></td></tr>
|
||||
<tr><td class="col-idx">3</td><td>合同 C</td><td class="is-num">43000</td><td>启用</td><td>待复核</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>6. 边框变体:只有竖向线(+ 外框)</h2>
|
||||
<p class="note">列与列之间有竖线,行与行之间没有横线;外框保留。</p>
|
||||
<table class="tbl bordered-y">
|
||||
<thead>
|
||||
<tr><th class="col-idx">#</th><th>名称</th><th class="is-num">金额</th><th>状态</th><th>备注</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td class="col-idx">1</td><td>合同 A</td><td class="is-num">12000</td><td>启用</td><td>已归档</td></tr>
|
||||
<tr><td class="col-idx">2</td><td>合同 B</td><td class="is-num">0</td><td>停用</td><td></td></tr>
|
||||
<tr><td class="col-idx">3</td><td>合同 C</td><td class="is-num">43000</td><td>启用</td><td>待复核</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ============================================================
|
||||
行编辑
|
||||
约定:一行同时只有一格处于编辑态;编辑态那一格退掉校验底色,
|
||||
改用语义色焦点环 + 红字(与组件库一致)。
|
||||
============================================================ */
|
||||
(function () {
|
||||
var grid = document.getElementById('grid1');
|
||||
var tbody = grid.tBodies[0];
|
||||
|
||||
/** 取某格当前的值(编辑态读 input,展示态读 .ro 的文字) */
|
||||
function readValue(td) {
|
||||
var input = td.querySelector('.cell-input');
|
||||
if (input) return input.value;
|
||||
var ro = td.querySelector('.ro');
|
||||
return ro ? ro.textContent : td.textContent.trim();
|
||||
}
|
||||
|
||||
/** 校验:返回 'error' | 'warning' | null */
|
||||
function validate(td, value) {
|
||||
var rule = td.dataset.validate;
|
||||
if (!rule) return null;
|
||||
if (rule === 'positive') {
|
||||
if (String(value).trim() === '') return 'error';
|
||||
return Number(value) > 0 ? null : 'error';
|
||||
}
|
||||
if (rule === 'notEmpty') {
|
||||
return String(value).trim() === '' ? 'warning' : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 提示文案 */
|
||||
function messageFor(rule, level) {
|
||||
if (rule === 'positive') return level === 'error' ? '金额必须大于 0' : '';
|
||||
if (rule === 'notEmpty') return level === 'warning' ? '建议填写备注' : '';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 把校验结果落到单元格上 */
|
||||
function paint(td, level) {
|
||||
td.classList.toggle('is-error', level === 'error');
|
||||
td.classList.toggle('is-warning', level === 'warning');
|
||||
|
||||
var rule = td.dataset.validate;
|
||||
var msg = td.querySelector('.cell-msg');
|
||||
if (level) {
|
||||
td.classList.add('has-msg');
|
||||
if (!msg) {
|
||||
msg = document.createElement('span');
|
||||
msg.className = 'cell-msg';
|
||||
td.appendChild(msg);
|
||||
}
|
||||
msg.textContent = messageFor(rule, level);
|
||||
} else {
|
||||
td.classList.remove('has-msg');
|
||||
if (msg) msg.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** 进入编辑:展示 span 换成 input */
|
||||
function enterEdit(td) {
|
||||
if (td.classList.contains('is-editing')) return;
|
||||
var value = readValue(td);
|
||||
var isNum = td.classList.contains('is-num');
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.className = 'cell-input';
|
||||
input.value = value;
|
||||
if (isNum) input.inputMode = 'decimal';
|
||||
|
||||
var ro = td.querySelector('.ro');
|
||||
if (ro) ro.style.display = 'none';
|
||||
td.insertBefore(input, td.firstChild);
|
||||
td.classList.add('is-editing');
|
||||
|
||||
// 实时校验
|
||||
input.addEventListener('input', function () {
|
||||
// 编辑态下先算结果,但底色让位(CSS 已处理),提示仍然实时更新
|
||||
var level = validate(td, input.value);
|
||||
// 编辑时不铺底色,但要把「离开编辑后该是什么状态」记在 dataset 上
|
||||
td.dataset.pending = level || '';
|
||||
var msg = td.querySelector('.cell-msg');
|
||||
if (level) {
|
||||
if (!msg) {
|
||||
msg = document.createElement('span');
|
||||
msg.className = 'cell-msg';
|
||||
td.appendChild(msg);
|
||||
}
|
||||
td.classList.add('has-msg');
|
||||
msg.textContent = messageFor(td.dataset.validate, level);
|
||||
} else if (msg) {
|
||||
msg.remove();
|
||||
td.classList.remove('has-msg');
|
||||
}
|
||||
});
|
||||
|
||||
input.focus();
|
||||
input.select();
|
||||
return input;
|
||||
}
|
||||
|
||||
/** 退出编辑:把 input 的值写回展示 span,并结算校验态 */
|
||||
function exitEdit(td) {
|
||||
if (!td.classList.contains('is-editing')) return;
|
||||
var input = td.querySelector('.cell-input');
|
||||
var value = input ? input.value : '';
|
||||
|
||||
if (input) input.remove();
|
||||
var ro = td.querySelector('.ro');
|
||||
if (ro) {
|
||||
ro.style.display = '';
|
||||
ro.textContent = value;
|
||||
}
|
||||
td.classList.remove('is-editing');
|
||||
|
||||
// 结算:写回后再按最终值判定一次
|
||||
var level = validate(td, value);
|
||||
paint(td, level);
|
||||
}
|
||||
|
||||
/** 收集一行里所有可编辑格 */
|
||||
function editableCells(tr) {
|
||||
return Array.prototype.filter.call(tr.cells, function (td) {
|
||||
return td.dataset.field && !td.classList.contains('is-disabled');
|
||||
});
|
||||
}
|
||||
|
||||
/** 进入某行的编辑态:把所有可编辑格变成 input,操作列换成保存/取消 */
|
||||
function editRow(tr) {
|
||||
editableCells(tr).forEach(enterEdit);
|
||||
|
||||
var op = tr.querySelector('[data-op]');
|
||||
op.innerHTML =
|
||||
'<button class="btn btn-primary" data-act="save">保存</button>' +
|
||||
'<button class="btn" data-act="cancel">取消</button>';
|
||||
|
||||
tr.dataset.editing = '1';
|
||||
}
|
||||
|
||||
/** 退出某行编辑态 */
|
||||
function saveRow(tr, revert) {
|
||||
var cells = editableCells(tr);
|
||||
|
||||
if (revert) {
|
||||
// 取消:把 pending 清掉,按当前展示值重算(展示值此时就是原值)
|
||||
cells.forEach(function (td) {
|
||||
var input = td.querySelector('.cell-input');
|
||||
if (input) {
|
||||
var ro = td.querySelector('.ro');
|
||||
var original = ro ? ro.dataset.original : '';
|
||||
input.value = original != null ? original : input.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cells.forEach(exitEdit);
|
||||
|
||||
var op = tr.querySelector('[data-op]');
|
||||
var locked = tr.dataset.locked === '1';
|
||||
op.innerHTML =
|
||||
'<button class="btn btn-link" data-act="edit"' + (locked ? ' disabled' : '') + '>编辑</button>' +
|
||||
'<button class="btn btn-link btn-danger" data-act="del"' + (locked ? ' disabled' : '') + '>删除</button>';
|
||||
|
||||
delete tr.dataset.editing;
|
||||
}
|
||||
|
||||
/* 记住每格的原始值,供「取消」回滚 */
|
||||
Array.prototype.forEach.call(tbody.rows, function (tr) {
|
||||
Array.prototype.forEach.call(tr.cells, function (td) {
|
||||
var ro = td.querySelector('.ro');
|
||||
if (ro) ro.dataset.original = ro.textContent;
|
||||
});
|
||||
});
|
||||
|
||||
/* 事件委托:编辑 / 保存 / 取消 / 删除 */
|
||||
grid.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-act]');
|
||||
if (!btn || btn.disabled) return;
|
||||
|
||||
var tr = btn.closest('tr');
|
||||
var act = btn.dataset.act;
|
||||
|
||||
if (act === 'edit') {
|
||||
editRow(tr);
|
||||
} else if (act === 'save') {
|
||||
saveRow(tr, false);
|
||||
} else if (act === 'cancel') {
|
||||
saveRow(tr, true);
|
||||
} else if (act === 'del') {
|
||||
if (tr.dataset.editing === '1') return; // 编辑中不给删,避免状态错乱
|
||||
tr.remove();
|
||||
renumber();
|
||||
}
|
||||
});
|
||||
|
||||
/* 回车保存 / Esc 取消 */
|
||||
grid.addEventListener('keydown', function (e) {
|
||||
var tr = e.target.closest('tr');
|
||||
if (!tr || tr.dataset.editing !== '1') return;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
saveRow(tr, false);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
saveRow(tr, true);
|
||||
}
|
||||
});
|
||||
|
||||
/* 删行后重排序号 */
|
||||
function renumber() {
|
||||
Array.prototype.forEach.call(tbody.rows, function (tr, i) {
|
||||
tr.cells[0].textContent = String(i + 1);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user