+
+ {{ qc.label }}
+
+
+
+ { cond.fieldId = v; onFilterFieldChange(cond) }"
+ >
+
+
+ { cond.op = v; onFilterOpChange(cond) }"
>
- 清除筛选
+
+
+ { cond.value = v }"
+ >
+
+
+ { cond.value = v }"
+ />
+
+ ×
+
+
+
+ 添加条件
+
+
+ 清除全部
收起
@@ -671,6 +817,16 @@ defineExpose({ applyEnterAction })
表设置(字段 / 表名)
+
+
+ {{ rowHeightMode === 'fixed' ? '✓' : '' }} 固定行高(超出省略)
+
+
+
+
+ {{ rowHeightMode === 'auto' ? '✓' : '' }} 自适应行高(随内容撑高)
+
+
清空当前表(仅记录)
删除当前表
@@ -694,6 +850,7 @@ defineExpose({ applyEnterAction })
size="small"
border
row-key="id"
+ :class="rowHeightMode === 'auto' ? 'row-height-auto' : 'row-height-fixed'"
@selection-change="(rows: Row[]) => { selectedIds = new Set(rows.map((r) => r.id)) }"
@row-dblclick="(row: Row) => openEdit(row)"
@sort-change="onSortChange"
@@ -769,6 +926,7 @@ defineExpose({ applyEnterAction })
v-model:visible="showRowForm"
:table="activeTable ?? { id: '', name: '', fields: [], createdAt: 0, updatedAt: 0 }"
:row="editingRow"
+ :rows="activeRows"
:prefill-text="enterPayload"
@save="onSaveRow"
/>
@@ -776,7 +934,10 @@ defineExpose({ applyEnterAction })
v-model:visible="showFieldMgr"
:table="activeTable ?? { id: '', name: '', fields: [], createdAt: 0, updatedAt: 0 }"
:row-count="activeRows.length"
+ :rows="activeRows"
@save="onSaveSchema"
+ @backfill="onBackfill"
+ @coerce="onCoerce"
/>
meta.value.multiSeparator || '、')
+ const rowHeightMode = computed<'fixed' | 'auto'>(() => meta.value.rowHeightMode ?? 'fixed')
function ensureBootstrapped() {
if (loaded.value) return
@@ -130,6 +131,17 @@ export function useStore() {
persistMeta()
}
+ function setRowHeightMode(mode: 'fixed' | 'auto') {
+ meta.value.rowHeightMode = mode
+ persistMeta()
+ }
+
+ /** 新增字段回填默认值:更新内存行 + bulkDocs 一次持久化整表 */
+ async function backfillRows(tableId: string, rows: Row[]): Promise {
+ rowsByTable.value = { ...rowsByTable.value, [tableId]: rows }
+ await replaceRows(tableId, rows)
+ }
+
function upsertRow(row: Row) {
const list = rowsByTable.value[row.tableId] ? [...rowsByTable.value[row.tableId]] : []
const idx = list.findIndex((r) => r.id === row.id)
@@ -268,6 +280,7 @@ function collectAllRows(): Record {
activeTable,
activeRows,
multiSeparator,
+ rowHeightMode,
ensureBootstrapped,
setActiveTable,
addTable,
@@ -276,6 +289,8 @@ function collectAllRows(): Record {
removeTable,
setMultiSeparator,
setQuickTableId,
+ setRowHeightMode,
+ backfillRows,
createRow,
saveRow,
deleteRow,
diff --git a/plugins/easytable/src/domain/codec/tableCodec.ts b/plugins/easytable/src/domain/codec/tableCodec.ts
index 2a9a14332..e16c031b5 100644
--- a/plugins/easytable/src/domain/codec/tableCodec.ts
+++ b/plugins/easytable/src/domain/codec/tableCodec.ts
@@ -1,6 +1,6 @@
import type { FieldDef, FieldValue, Row, TableSchema } from '../../types/table.ts'
import { CREATED_AT_FIELD_ID, UPDATED_AT_FIELD_ID, isMultiValue } from '../../types/table.ts'
-import { coerceFieldValue, defaultValue, displayValue } from '../fieldTypes.ts'
+import { coerceFieldValue, displayValue, initialFieldDefault } from '../fieldTypes.ts'
import { encodeCsv, encodeTsv, parseCsv, parseDelimited, parseTsv } from './tsv.ts'
import { DEFAULT_MULTI_SEP, splitMultiValue } from '../separators.ts'
import { formatTimestamp } from '../../utils/time.ts'
@@ -21,7 +21,8 @@ export function formatCell(
value: FieldValue | undefined,
multiSep = DEFAULT_MULTI_SEP
): string {
- return displayValue(field, value ?? defaultValue(field.type), multiSep)
+ // 导出/展示兜底:缺失值显示为空,不编造字段默认值
+ return displayValue(field, value ?? null, multiSep)
}
function cellForRow(field: FieldDef, row: Row, multiSep: string): string {
@@ -93,7 +94,7 @@ export function mapRowValues(
multiSep = DEFAULT_MULTI_SEP
): Record {
const values: Record = {}
- for (const f of fields) values[f.id] = defaultValue(f.type)
+ for (const f of fields) values[f.id] = initialFieldDefault(f)
for (let col = 0; col < headers.length; col += 1) {
const fieldId = columnMap[col]
diff --git a/plugins/easytable/src/domain/fieldTypes.ts b/plugins/easytable/src/domain/fieldTypes.ts
index dcfbd8215..28730dbe6 100644
--- a/plugins/easytable/src/domain/fieldTypes.ts
+++ b/plugins/easytable/src/domain/fieldTypes.ts
@@ -1,4 +1,5 @@
import type { FieldDef, FieldValue, FieldType } from '../types/table.ts'
+import { hasOptions } from '../types/table.ts'
import { DEFAULT_MULTI_SEP, splitMultiValue } from './separators.ts'
export const FIELD_TYPE_LABELS: Record = {
@@ -32,6 +33,32 @@ export function defaultValue(type: FieldType): FieldValue {
}
}
+/** 新建行时取字段默认值:配置了 default 用之,否则按类型给空值 */
+export function initialFieldDefault(field: FieldDef): FieldValue {
+ if (field.default !== undefined) return field.default
+ return defaultValue(field.type)
+}
+
+/**
+ * 新增字段回填已有数据:field.default 存在且非空时,把该值写进「缺失该字段」的行。
+ * 返回新行数组与被改写的行数;无变化返回 null。
+ */
+export function backfillField }>(
+ rows: T[],
+ field: FieldDef
+): { rows: T[]; changedRows: number } | null {
+ const v = field.default
+ // 空串 / null / undefined / 空数组都视为「未设置默认值」,不回填
+ if (v == null || v === '' || (Array.isArray(v) && !v.length)) return null
+ let changedRows = 0
+ const next = rows.map((r) => {
+ if (r.values[field.id] !== undefined) return r
+ changedRows += 1
+ return { ...r, values: { ...r.values, [field.id]: v } }
+ })
+ return changedRows ? { rows: next, changedRows } : null
+}
+
export function createField(name: string, type: FieldType): FieldDef {
const id = `f_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
if (type === 'select' || type === 'multi_select') {
@@ -112,3 +139,87 @@ export function searchTextOf(
.join(' ')
.toLowerCase()
}
+
+/**
+ * 一个值在某字段下会拆成哪些候选选项:单选取整串,多选/列表按「、 , , |」拆分。
+ * 与 coerceFieldValue 同一套语义,保证「选项」和「转换后真正存下的值」能对上。
+ */
+function optionCandidates(field: FieldDef, value: FieldValue | undefined): string[] {
+ if (value == null || value === '') return []
+ const coerced = coerceFieldValue(field, value)
+ const list = Array.isArray(coerced) ? coerced : coerced === '' ? [] : [String(coerced)]
+ return list.map((s) => String(s).trim()).filter(Boolean)
+}
+
+/**
+ * 把某个值收进单选/多选的选项列表(会就地修改 field.options)。
+ * 返回是否新增了选项。
+ */
+export function addFieldOptions(field: FieldDef, value: FieldValue): boolean {
+ if (!hasOptions(field)) return false
+ let changed = false
+ for (const s of optionCandidates(field, value)) {
+ if (field.options.includes(s)) continue
+ field.options.push(s)
+ changed = true
+ }
+ return changed
+}
+
+/**
+ * 收集某字段在已有行里的全部不同取值(按出现顺序去重,跳过空值)。
+ * 「文本 → 单选/多选」时用它生成选项,转换后已有数据不会变成下拉里选不到的值。
+ */
+export function collectFieldOptions(
+ rows: Array<{ values: Record }>,
+ field: FieldDef
+): string[] {
+ const out: string[] = []
+ const seen = new Set()
+ for (const row of rows) {
+ for (const s of optionCandidates(field, row.values[field.id])) {
+ if (seen.has(s)) continue
+ seen.add(s)
+ out.push(s)
+ }
+ }
+ return out
+}
+
+/** 值相等判定:数组逐项比较,其余用 === */
+function sameFieldValue(a: FieldValue | undefined, b: FieldValue): boolean {
+ if (Array.isArray(a) || Array.isArray(b)) {
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
+ return a.every((v, i) => v === b[i])
+ }
+ return a === b
+}
+
+/**
+ * 字段改了类型后,按新类型转换已有行的值(只处理 fieldIds 指定的字段)。
+ * 返回新行数组与被改写的行数;全部无变化返回 null。
+ * 有意不动行上的 updatedAt:类型迁移属于结构调整,不该把「修改时间」列全部刷新。
+ */
+export function coerceRowsToFields }>(
+ rows: T[],
+ fields: FieldDef[],
+ fieldIds: string[]
+): { rows: T[]; changedRows: number } | null {
+ const targets = fields.filter((f) => fieldIds.includes(f.id))
+ if (!targets.length) return null
+ let changedRows = 0
+ const next = rows.map((row) => {
+ let values: Record | null = null
+ for (const f of targets) {
+ const raw = row.values[f.id]
+ const coerced = coerceFieldValue(f, raw)
+ if (sameFieldValue(raw, coerced)) continue
+ values = values ?? { ...row.values }
+ values[f.id] = coerced
+ }
+ if (!values) return row
+ changedRows += 1
+ return { ...row, values }
+ })
+ return changedRows ? { rows: next, changedRows } : null
+}
diff --git a/plugins/easytable/src/domain/filters.ts b/plugins/easytable/src/domain/filters.ts
new file mode 100644
index 000000000..743b01a0d
--- /dev/null
+++ b/plugins/easytable/src/domain/filters.ts
@@ -0,0 +1,164 @@
+// 高级筛选:AND 条件组 + 时间范围 + 空值判定(纯函数,node 可直跑)
+// 条件模型见 types/table.ts 的 FilterCond;本文件只做「派生操作符 + 行匹配 + 时间范围」
+
+import type { FieldDef, FilterCond, FilterOp } from '../types/table.ts'
+import { CREATED_AT_FIELD_ID, UPDATED_AT_FIELD_ID } from '../types/table.ts'
+import { displayValue } from './fieldTypes.ts'
+
+/** 系统列的虚拟字段定义(筛选/表单共用) */
+export function systemField(id: string): FieldDef {
+ if (id === UPDATED_AT_FIELD_ID) {
+ return { id: UPDATED_AT_FIELD_ID, name: '修改时间', type: 'date' }
+ }
+ return { id: CREATED_AT_FIELD_ID, name: '创建时间', type: 'date' }
+}
+
+/** 筛选面板可用的字段:用户字段 + 两个系统时间列 */
+export function filterableFields(fields: FieldDef[]): FieldDef[] {
+ return [...fields, systemField(CREATED_AT_FIELD_ID), systemField(UPDATED_AT_FIELD_ID)]
+}
+
+/** 按字段类型派生可用操作符 */
+export function opsForField(f: FieldDef): Array<{ op: FilterOp; label: string }> {
+ if (f.id === CREATED_AT_FIELD_ID || f.id === UPDATED_AT_FIELD_ID) {
+ // 系统时间列恒有值,没有「为空」语义
+ return [
+ { op: 'today', label: '今天' },
+ { op: 'thisWeek', label: '本周' },
+ { op: 'thisMonth', label: '本月' }
+ ]
+ }
+ const list: Array<{ op: FilterOp; label: string }> = [
+ { op: 'empty', label: '为空' },
+ { op: 'notEmpty', label: '不为空' }
+ ]
+ if (f.type === 'select' || f.type === 'multi_select' || f.type === 'list') {
+ list.push({ op: 'eq', label: '属于选项' })
+ }
+ if (
+ f.type === 'text' ||
+ f.type === 'longtext' ||
+ f.type === 'url' ||
+ f.type === 'select' ||
+ f.type === 'number'
+ ) {
+ list.push({ op: 'contains', label: '包含' })
+ }
+ if (f.type === 'date') {
+ list.push({ op: 'today', label: '今天' }, { op: 'thisWeek', label: '本周' }, { op: 'thisMonth', label: '本月' })
+ }
+ return list
+}
+
+/** 空值判定:null / '' / 空数组算空;checkbox 的 false 是有效值不算空 */
+export function isEmptyValue(f: FieldDef, v: unknown): boolean {
+ if (f.type === 'checkbox') return false
+ if (v == null) return true
+ if (Array.isArray(v)) return v.length === 0
+ return String(v) === ''
+}
+
+/**
+ * date 字段存 'YYYY-MM-DD';Date.parse 会按 UTC 解析差 8 小时,
+ * 必须手动拆解成本地时区的当天 00:00。
+ */
+export function parseLocalDate(s: string): number | null {
+ const m = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/.exec(s.trim())
+ if (!m) return null
+ const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime()
+ return Number.isFinite(t) ? t : null
+}
+
+function dayStart(ts: number): number {
+ const d = new Date(ts)
+ d.setHours(0, 0, 0, 0)
+ return d.getTime()
+}
+
+/** 本周 = 周一起始:[周一 00:00, 下周一 00:00) */
+export function weekRange(now: number): [number, number] {
+ const d = new Date(dayStart(now))
+ const dow = d.getDay() === 0 ? 7 : d.getDay() // 周日=0 视为第 7 天
+ d.setDate(d.getDate() - (dow - 1))
+ const start = d.getTime()
+ return [start, start + 7 * 86400_000]
+}
+
+export function monthRange(now: number): [number, number] {
+ const d = new Date(now)
+ const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime()
+ const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
+ return [start, end]
+}
+
+function timeRangeOf(op: FilterOp, now: number): [number, number] | null {
+ if (op === 'today') {
+ const start = dayStart(now)
+ return [start, start + 86400_000]
+ }
+ if (op === 'thisWeek') return weekRange(now)
+ if (op === 'thisMonth') return monthRange(now)
+ return null
+}
+
+/** 取行里某字段的可比较时间戳;无值返回 null */
+function rowTimestamp(f: FieldDef, row: { createdAt: number; updatedAt: number; values: Record }): number | null {
+ if (f.id === CREATED_AT_FIELD_ID) return row.createdAt || null
+ if (f.id === UPDATED_AT_FIELD_ID) return row.updatedAt || null
+ const raw = row.values[f.id]
+ if (raw == null || raw === '') return null
+ return parseLocalDate(String(raw))
+}
+
+/** 单条件匹配(now 供时间条件计算范围,测试可注入固定时间) */
+export function matchCond(
+ cond: FilterCond,
+ row: { createdAt: number; updatedAt: number; values: Record },
+ fields: FieldDef[],
+ now: number,
+ multiSep = '、'
+): boolean {
+ const f =
+ cond.fieldId === CREATED_AT_FIELD_ID
+ ? systemField(CREATED_AT_FIELD_ID)
+ : cond.fieldId === UPDATED_AT_FIELD_ID
+ ? systemField(UPDATED_AT_FIELD_ID)
+ : fields.find((x) => x.id === cond.fieldId)
+ if (!f) return true // 字段已被删掉:条件失效但不拦人,避免「筛出 0 行还找不到原因」
+
+ if (cond.op === 'empty' || cond.op === 'notEmpty') {
+ const empty = isEmptyValue(f, f.id in row.values ? row.values[f.id] : null)
+ // 系统时间列恒有值;date 字段走同一套空值判定
+ return cond.op === 'empty' ? empty : !empty
+ }
+
+ if (cond.op === 'today' || cond.op === 'thisWeek' || cond.op === 'thisMonth') {
+ const ts = rowTimestamp(f, row)
+ if (ts == null) return false
+ const range = timeRangeOf(cond.op, now)
+ if (!range) return false
+ return ts >= range[0] && ts < range[1]
+ }
+
+ if (cond.op === 'eq') {
+ const want = cond.value
+ const v = row.values[f.id]
+ if (Array.isArray(v)) return v.includes(want)
+ return String(v ?? '') === want
+ }
+
+ // contains:用展示文本比(多值用分隔符拼起来),大小写不敏感
+ const hay = displayValue(f, (row.values[f.id] ?? null) as never, multiSep).toLowerCase()
+ return hay.includes(cond.value.trim().toLowerCase())
+}
+
+/** 整组条件(AND)是否匹配 */
+export function matchAll(
+ conds: FilterCond[],
+ row: { id: string; createdAt: number; updatedAt: number; values: Record },
+ fields: FieldDef[],
+ now: number,
+ multiSep = '、'
+): boolean {
+ return conds.every((c) => matchCond(c, row, fields, now, multiSep))
+}
diff --git a/plugins/easytable/src/types/table.ts b/plugins/easytable/src/types/table.ts
index 01b062f5b..693e0e107 100644
--- a/plugins/easytable/src/types/table.ts
+++ b/plugins/easytable/src/types/table.ts
@@ -11,13 +11,22 @@ export type FieldType =
export type FieldValue = string | number | boolean | string[] | null
+/** 字段默认值:新建行预填;新增字段保存时可选回填到已有数据(回填与否是一次性决策,不持久化) */
+export type FieldDefault = FieldValue
+
export type FieldDef =
- | { id: string; name: string; type: 'text' | 'longtext' | 'url' }
- | { id: string; name: string; type: 'list' }
- | { id: string; name: string; type: 'select' | 'multi_select'; options: string[] }
- | { id: string; name: string; type: 'number' }
- | { id: string; name: string; type: 'date' }
- | { id: string; name: string; type: 'checkbox' }
+ | ({ id: string; name: string; type: 'text' | 'longtext' | 'url'; default?: FieldDefault })
+ | ({ id: string; name: string; type: 'list'; default?: FieldDefault })
+ | ({
+ id: string
+ name: string
+ type: 'select' | 'multi_select'
+ options: string[]
+ default?: FieldDefault
+ })
+ | ({ id: string; name: string; type: 'number'; default?: FieldDefault })
+ | ({ id: string; name: string; type: 'date'; default?: FieldDefault })
+ | ({ id: string; name: string; type: 'checkbox'; default?: FieldDefault })
export interface TableSchema {
id: string
@@ -46,6 +55,25 @@ export interface Row {
updatedAt: number
}
+export type FilterOp =
+ | 'empty'
+ | 'notEmpty'
+ | 'eq'
+ | 'contains'
+ | 'today'
+ | 'thisWeek'
+ | 'thisMonth'
+
+/** 高级筛选条件;多条之间 AND */
+export interface FilterCond {
+ /** 条件实例 id(列表 key) */
+ key: string
+ fieldId: string
+ op: FilterOp
+ /** 仅 eq / contains 使用 */
+ value: string
+}
+
export interface AppMeta {
version: 1
tables: TableSchema[]
@@ -53,6 +81,8 @@ export interface AppMeta {
multiSeparator: string
/** 快捷记一笔上次使用的表 */
quickTableId?: string
+ /** 行高样式:fixed=固定行高(单行省略),auto=自适应(随内容撑高);默认 fixed */
+ rowHeightMode?: 'fixed' | 'auto'
}
export interface BackupFile {