20260923165454

This commit is contained in:
oneao committed 2026-09-23 16:54:54 +08:00
1 parent 0abe3706c8
commit e6935b8fa4
173 files changed
+14975 -2295

No files matched your search

@@ -2,6 +2,8 @@
// 供 fms-module-list / fms-module-edit 等按模块元数据渲染的组件共用;
// 展示格式化(parseOptions / formatDateValue)见 fieldUtils.js。
import dayjs from 'dayjs'
export const identifierRe = /^[A-Za-z_][A-Za-z0-9_]*$/
export function escapeSqlText(value) {
@@ -107,19 +109,16 @@ export function sqlLiteral(value, field, endOfRange = false) {
}
/* ---------- 统一查询条件:AST → SQL ----------
* 常用条件与高级条件共用同一套结构:差别只在操作符的来源
* (常用取模块配置 b_operator / 字段默认操作符,高级由用户选择)。
* AST 形状:
* 页签条件、关键词、快捷筛选、更多筛选都归一到同一套结构,最终由 astToSql 生成 SQL:
* 条件 { type: 'condition', field, operator, value, join }
* 条件组 { type: 'group', children: [条件 | 条件组] }
* 条件组 { type: 'group', children: [条件 | 条件组], logic? }
* join 表示「本项与上一相邻项如何连接」(且 / 或);首项 join 忽略。
* 高级查询即用每项的 join 表达同层混合逻辑(如 a 且 b 或 c = (a 且 b) 或 c),
* 不再有「整层统一逻辑」的概念;常用查询的 join 一律为且。
* 页签 / 快捷筛选 / 更多筛选同层一律「且」,只有关键词在多个字段之间取「或」。
*/
/** 操作符枚举(按字段类型分组,唯一定义处;界面文案由使用方自行映射) */
export const OPERATORS = {
text: ['like', 'not_like', 'eq', 'ne', 'is_null', 'is_not_null'],
text: ['like', 'starts_with', 'eq', 'ne', 'not_like', 'is_null', 'is_not_null'],
number: ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'between', 'is_null', 'is_not_null'],
date: ['between', 'ge', 'le', 'is_null', 'is_not_null'],
enum: ['eq', 'ne', 'in', 'not_in', 'is_null', 'is_not_null'],
@@ -130,6 +129,7 @@ const RANGE_SIGNS = { gt: '>', ge: '>=', lt: '<', le: '<=' }
/** 操作符文案(中文默认值 + 多语言资源键;条件摘要与操作符下拉共用,唯一定义处) */
const OPERATOR_TEXT = {
like: '包含',
starts_with: '开头是',
not_like: '不包含',
eq: '等于',
ne: '不等于',
@@ -146,6 +146,7 @@ const OPERATOR_TEXT = {
const OPERATOR_I18N = {
like: 'common.opLike',
starts_with: 'common.opStartsWith',
not_like: 'common.opNotLike',
eq: 'common.opEq',
ne: 'common.opNe',
@@ -175,15 +176,6 @@ export function operatorShortLabel(operator, translate) {
return operatorLabel(operator, translate)
}
const LOGIC_TEXT = { and: '且', or: '或' }
const LOGIC_I18N = { and: 'common.logicAnd', or: 'common.logicOr' }
/** 逻辑连接符短文案:条件层之间的药丸用(完整短语「全部满足」留给无障碍标签) */
export function logicLabel(logic, translate) {
const fallback = LOGIC_TEXT[logic] || logic
return typeof translate === 'function' ? translate(LOGIC_I18N[logic], fallback) : fallback
}
/** 字段 → 操作符分组,决定可选操作符与默认操作符 */
export function operatorGroup(field) {
const type = field?.b_type
@@ -221,6 +213,64 @@ export function emptyConditionValue(field, operator) {
return undefined
}
/* ---------- 日期预设区间 ----------
* 快捷筛选的日期控件与工作队列页签的预设条件都用语义值 { preset } 表达区间,
* 取数时才解析成具体日期 —— 配置里不写死日期,页签「今天」「本月」才不会过期。
*/
export const DATE_PRESETS = [
{ value: 'today', i18n: 'common.datePresetToday', label: '今天' },
{ value: 'this_week', i18n: 'common.datePresetThisWeek', label: '本周' },
{ value: 'this_month', i18n: 'common.datePresetThisMonth', label: '本月' },
{ value: 'recent_3_months', i18n: 'common.datePresetRecent3Months', label: '近三个月' },
]
/** 日期预设文案:多语言键优先,缺翻译回退中文 */
export function datePresetLabel(value, translate) {
const preset = DATE_PRESETS.find((item) => item.value === value)
if (!preset) return String(value ?? '')
return typeof translate === 'function' ? translate(preset.i18n, preset.label) : preset.label
}
/**
* 日期预设 → [开始日, 结束日](YYYY-MM-DD)。未知预设返回 null。
* 本周以周一为起点、周日为终点;近三个月含当月,即自三个月前的次日到今天。
* @param now 可注入的当前时刻(测试用),缺省取系统时间
*/
export function resolveDatePreset(preset, now) {
const base = now ? dayjs(now) : dayjs()
if (!base.isValid()) return null
if (preset === 'today') {
const day = base.format('YYYY-MM-DD')
return [day, day]
}
if (preset === 'this_week') {
// dayjs 的 day() 周日为 0,换算成「周一=1、周日=7」
const weekday = base.day() === 0 ? 7 : base.day()
const start = base.subtract(weekday - 1, 'day')
return [start.format('YYYY-MM-DD'), start.add(6, 'day').format('YYYY-MM-DD')]
}
if (preset === 'this_month') {
return [base.startOf('month').format('YYYY-MM-DD'), base.endOf('month').format('YYYY-MM-DD')]
}
if (preset === 'recent_3_months') {
return [base.subtract(3, 'month').add(1, 'day').format('YYYY-MM-DD'), base.format('YYYY-MM-DD')]
}
return null
}
/** 语义值 { preset } 是否为日期预设 */
export function isDatePresetValue(value) {
return Boolean(
value && typeof value === 'object' && !Array.isArray(value) && typeof value.preset === 'string',
)
}
/** 条件值归一:日期预设解析成具体区间,其余原样返回 */
export function resolveDatePresetValue(value) {
if (!isDatePresetValue(value)) return value
return resolveDatePreset(value.preset) || []
}
/**
* 单个条件 → SQL 片段。
* @param node 条件 { field, operator, value }
@@ -261,10 +311,12 @@ export function buildConditionSql(node, fieldMap) {
return `${column} ${operator === 'in' ? 'IN' : 'NOT IN'} (${literals.join(', ')})`
}
if (operator === 'like' || operator === 'not_like') {
if (operator === 'like' || operator === 'not_like' || operator === 'starts_with') {
if (isEmptyValue(value)) return ''
const keyword = operator === 'like' ? 'LIKE' : 'NOT LIKE'
return `${column} ${keyword} N'%${escapeSqlText(String(value))}%'`
const keyword = operator === 'not_like' ? 'NOT LIKE' : 'LIKE'
// 开头是:只在前缀通配;包含:前后都通配
const pattern = operator === 'starts_with' ? `${escapeSqlText(String(value))}%` : `%${escapeSqlText(String(value))}%`
return `${column} ${keyword} N'${pattern}'`
}
if (RANGE_SIGNS[operator]) {
@@ -324,7 +376,7 @@ export function astToSql(ast, fieldMap) {
return buildConditionSql(ast, fieldMap)
}
/** 常用条件表单值 → AST(操作符取模块配置 b_operator,缺省按字段类型) */
/** 快捷筛选表单值 → AST(操作符取模块配置 b_operator,缺省按字段类型) */
export function buildQuickAst(items, values) {
const children = []
for (const item of items || []) {
@@ -336,46 +388,96 @@ export function buildQuickAst(items, values) {
type: 'condition',
field: field.b_field,
operator: item.config?.b_operator || defaultOperator(field),
value,
value: resolveDatePresetValue(value),
})
}
return { type: 'group', logic: 'and', children }
}
function toConditionNode(node) {
return {
type: 'condition',
field: node.field,
operator: node.operator,
value: node.value,
join: node.join,
/**
* 工作队列页签 → AST。
* 页签条件是配置里写好的完整条件(含日期预设),直接沿用 AST 形状交给 astToSql,
* 因此页签不需要第二套 SQL 生成逻辑。
* @param view query JSON 中的页签节点({ conditions: [...] })
* @param fieldMap 「字段编码 → s_field 行」的 Map;字段缺失的条件直接丢弃
*/
export function buildViewAst(view, fieldMap) {
const children = []
for (const condition of view?.conditions || []) {
if (!condition || condition.field == null) continue
const field = String(condition.field)
if (fieldMap && !fieldMap.has(field)) continue
children.push({
type: 'condition',
field,
operator: condition.operator,
value: resolveDatePresetValue(condition.value),
})
}
return children.length ? { type: 'group', children } : null
}
/**
* 高级条件(条件行 + 条件组混在同一层)→ AST。
* 每个子项自带 join(本项与上一相邻项的连接方式,且 / 或),首项 join 忽略。
* 同层混用 且/或 直接可表达:如 (a 且 b) 或 c —— a、b 的 join 为且,c 的 join 为或。
* 条件组内部同理,组自身也带 join(决定组与根层上一相邻项的连接)。
* 关键词 → AST:配置的可搜索字段之间取「或」(命中任一字段即算匹配)。
* 未配置字段或关键词为空时返回 null(不产生条件)。
* @param keyword 用户输入
* @param items 关键词字段对应的查询条件项(含 field)
*/
export function buildAdvancedAst({ nodes = [], groups = [] } = {}) {
export function buildKeywordAst(keyword, items) {
const text = String(keyword ?? '').trim()
if (!text) return null
const children = []
for (const node of nodes || []) {
if (hasConditionValue(node.value)) children.push(toConditionNode(node))
for (const item of items || []) {
const field = item?.field
if (!field) continue
children.push({
type: 'condition',
field: field.b_field,
operator: 'like',
value: text,
join: children.length ? 'or' : undefined,
})
}
for (const group of groups || []) {
const innerNodes = (group.nodes || [])
.filter((node) => hasConditionValue(node.value))
.map(toConditionNode)
// 单条件的组不再套一层括号,语义等价(没有连接符可表达),只保留其 join
if (innerNodes.length === 1) children.push({ ...innerNodes[0], join: group.join })
else if (innerNodes.length > 1)
children.push({ type: 'group', join: group.join, children: innerNodes })
return children.length ? { type: 'group', children } : null
}
/**
* 合并多个 AST 为单一 AND 组(页签 / 关键词 / 快捷筛选 / 更多筛选各一棵)。
* 子组作为整体参与 AND(自身保留括号),空 AST 直接跳过;全部为空时返回 null。
*/
export function combineAst(...asts) {
const children = []
for (const ast of asts) {
if (!ast) continue
const inner = ast.type === 'group' ? ast.children || [] : [ast]
if (!inner.length) continue
children.push({ ...ast, join: 'and' })
}
if (!children.length) return null
return { type: 'group', children }
}
/* ---------- 高级条件中文描述(预览 / 回显,面板与页面共用) ---------- */
/**
* 更多筛选抽屉的草稿状态 → AST。
* 状态形如 { [b_field]: { operator, value } }:只收已填值的字段,同层一律「且」。
* 字段不在可选集合(无权限 / 未定义)时直接忽略。
*/
export function buildFilterStateAst(state, itemByField) {
const children = []
for (const [field, entry] of Object.entries(state || {})) {
if (!entry || !hasConditionValue(entry.value)) continue
if (itemByField && !itemByField.has(field)) continue
children.push({
type: 'condition',
field,
operator: entry.operator,
value: resolveDatePresetValue(entry.value),
})
}
return children.length ? { type: 'group', children } : null
}
/* ---------- 条件中文描述(条件回显标签用) ---------- */
/** 值 → 预览文本:区间用「~」连接、多选用顿号,其余原样 */
function formatPreviewValue(node, value) {
@@ -385,11 +487,12 @@ function formatPreviewValue(node, value) {
if (node.operator === 'between') return entries.join(' ~ ')
return entries.map((entry) => String(entry)).join('、')
}
if (isDatePresetValue(value)) return datePresetLabel(value.preset)
return isEmptyValue(value) ? '' : String(value)
}
/** 单个条件 → 中文描述;字段不可用或值未填时返回空串 */
function describeCondition(node, itemByField, translate) {
export function describeConditionText(node, itemByField, translate) {
if (
!hasConditionValue(node.value) &&
node.operator !== 'is_null' &&
@@ -406,120 +509,3 @@ function describeCondition(node, itemByField, translate) {
const valueText = formatPreviewValue(node, node.value)
return valueText ? `${label} ${operatorLabel(node.operator, translate)} ${valueText}` : ''
}
/** 条件组 → 中文描述:组内行按各自 join 连接 */
function describeGroup(group, itemByField, translate) {
return joinPreviewParts(
(group.nodes || [])
.map((node) => ({ text: describeCondition(node, itemByField, translate), join: node.join }))
.filter((part) => part.text),
translate,
)
}
/** 文本段列表按各自 join 连接(首项 join 忽略) */
function joinPreviewParts(parts, translate) {
if (!parts.length) return ''
let out = parts[0].text
for (let i = 1; i < parts.length; i++) {
out += ` ${logicLabel(parts[i].join, translate)} ${parts[i].text}`
}
return out
}
/**
* 高级条件 → 中文描述(输入与 buildAdvancedAst 同构的 { nodes, groups } 状态)。
* 每项自带 join(且 / 或),组整体加括号;用于「高级查询生效」时回显与预览。
* @param {Object} state 高级条件状态
* @param {Map} itemByField 字段编码 → 查询条件项(含 field / config / options)
* @param {Function} translate i18n 翻译函数,可空(回退中文默认值)
*/
export function describeAdvancedQuery(state, itemByField, translate) {
const children = [
...(state?.nodes || []).map((node) => ({ kind: 'node', at: node.at ?? 0, node })),
...(state?.groups || []).map((group) => ({ kind: 'group', at: group.at ?? 0, group })),
].toSorted((a, b) => a.at - b.at)
const parts = []
for (const child of children) {
const text =
child.kind === 'group'
? describeGroup(child.group, itemByField, translate)
: describeCondition(child.node, itemByField, translate)
if (!text) continue
parts.push({
text: child.kind === 'group' ? `(${text})` : text,
join: child.kind === 'node' ? child.node.join : child.group.join,
})
}
return joinPreviewParts(parts, translate)
}
/* ---------- AST → 高级条件界面状态(buildAdvancedAst 的反向,用于回显已保存的条件) ---------- */
let queryRowSeq = 0
function nextQueryRowId() {
queryRowSeq += 1
return `${Date.now()}-${queryRowSeq}`
}
/**
* AST → 高级条件界面状态。
* 界面只支持「根层 + 一层条件组」(与 FmsAdvancedQueryPanel 一致):组内再出现组时,
* 把内层 children 拍平进该组并置 degraded=true,由调用方决定是否提示「条件已按单层展开」。
* @param {Object} ast 条件 AST({ type:'group'|'condition', children, join, field, operator, value })
* @param {Object} [options] { idFactory } 自定义界面行 id 生成器(测试用)
* @returns {{ nodes: Array, groups: Array, degraded: boolean }}
*/
export function advancedQueryFromAst(ast, { idFactory } = {}) {
const makeId = typeof idFactory === 'function' ? idFactory : nextQueryRowId
const nodes = []
const groups = []
let degraded = false
let at = 0
const nextAt = () => {
at += 1
return at
}
const toNode = (condition, join) => ({
id: makeId(),
at: nextAt(),
join: join === 'or' ? 'or' : 'and',
field: condition.field == null ? '' : String(condition.field),
operator: condition.operator || 'eq',
value:
condition.value === undefined
? emptyConditionValue(undefined, condition.operator || 'eq')
: condition.value,
})
/** 组内子项 → 条件行(界面组只有一行层,嵌套组在这里被拍平) */
const flattenInto = (children, target) => {
for (const child of children || []) {
if (child?.type === 'group') {
degraded = true
flattenInto(child.children, target)
} else if (child?.type === 'condition') {
target.push(toNode(child, child.join))
}
}
}
const children = ast?.type === 'group' ? ast.children || [] : ast ? [ast] : []
for (const child of children) {
if (child?.type === 'group') {
const innerNodes = []
flattenInto(child.children, innerNodes)
groups.push({
id: makeId(),
at: nextAt(),
join: child.join === 'or' ? 'or' : 'and',
nodes: innerNodes,
})
} else if (child?.type === 'condition') {
nodes.push(toNode(child, child.join))
}
}
return { nodes, groups, degraded }
}
@@ -6,11 +6,13 @@
* 无需感知 JSON 节点差异。覆盖:
* - view → 列表列配置行(b_field / b_width / b_visible / b_xh,含节点 options)
* - edit → 编辑分组 + 编辑行(b_group_id / b_colspan / b_readonly / b_required 等)
* - query → 查询条件(field / operator / component / defaultValue)
* - query → 查询入口(工作队列页签 / 关键词字段 / 快捷筛选 / 更多筛选分组)
* - 列宽/顺序回写(栏位设置保存)→ 更新 view/edit JSON 节点
*/
import { loadDataApi } from '@/services/api'
import { normalizeGroupIds, parseSchema } from '@/utils/schemaJson'
import { parseOptions } from './fieldUtils'
import { defaultOperator } from './queryUtils'
/** 需要还原的界面配置类型 */
const RENDER_SCHEMA_TYPES = ['view', 'edit', 'query']
@@ -128,35 +130,109 @@ export function buildEditRender(editSchema) {
return { groups, rows }
}
/** query JSON → 查询条件列表(含 quick 顺序,供页面排序) */
export function buildQueryRender(querySchema) {
const conditions = []
for (const group of querySchema?.groups || []) {
for (const condition of group?.conditions || []) {
if (condition?.field == null) continue
conditions.push({
field: String(condition.field),
operator: condition.operator || 'like',
component: condition.component || '',
defaultValue: condition.defaultValue ?? null,
options: condition.options,
node: condition,
})
/**
* query JSON(schemaVersion 2)→ 查询入口渲染结构。
*
* 产出(运行时组件只消费这一份,不再感知 JSON 节点差异):
* - views 工作队列页签(按 order 排序)
* - defaultViewCode 模块配置的默认页签编码
* - keywordSearch 关键词搜索(占位文案 / 触发方式 / 字段项),未配置时为 null
* - quickFilters 快捷筛选字段项(按 order 排序)
* - filterGroups 更多筛选分组(组内字段项按配置顺序)
*
* 字段项(queryItem)统一为 { field, config, options, optionConfig },与 FmsQueryControl /
* buildQuickAst 消费的形状一致;字段级算子只在 queryFields 里定义一处。
*
* @param querySchema query JSON 对象
* @param fields 模块启用字段(s_field 行)
* @param allowedFields 允许参与查询的字段集合(字段查询权限裁剪后);null 表示不额外限制
*/
export function buildQueryRender(querySchema, fields, allowedFields = null) {
const fieldById = new Map((fields || []).map((field) => [String(field.b_field), field]))
const queryFields = querySchema?.queryFields || {}
const allowed = allowedFields ? new Set([...allowedFields].map(String)) : null
/** 字段编码 → 查询条件项;字段未定义或无查询权限时返回 null */
const itemOf = (rawField) => {
const name = String(rawField ?? '').trim()
if (!name) return null
const field = fieldById.get(name)
if (!field) return null
if (allowed && !allowed.has(name)) return null
const config = queryFields[name] || {}
const optionInfo = parseOptions({ options: config.options })
return {
field,
config: {
b_field: name,
b_operator: config.operator || defaultOperator(field),
b_component: config.component || '',
b_default_value: config.defaultValue ?? null,
},
options: optionInfo.options,
optionConfig: optionInfo.optionConfig,
}
}
const quick = Array.isArray(querySchema?.quick) ? [...querySchema.quick] : []
return { quick, conditions }
}
/** 按 quick 顺序优先排列条件(quick 未列出的保持原顺序追加在后) */
export function orderQueryConditions(conditions, quick) {
if (!quick?.length) return conditions
const rank = new Map(quick.map((field, index) => [String(field), index]))
return [...conditions].toSorted((a, b) => {
const ra = rank.has(a.field) ? rank.get(a.field) : Number.MAX_SAFE_INTEGER
const rb = rank.has(b.field) ? rank.get(b.field) : Number.MAX_SAFE_INTEGER
return ra - rb
})
const views = []
for (const [index, view] of (querySchema?.views || []).entries()) {
if (!view || view.code == null) continue
const conditions = (view.conditions || []).filter(
(condition) => condition && condition.field != null,
)
// 条件字段不可用(未定义 / 无查询权限)时丢弃整个页签:静默丢单个条件会把
// 「待处理」悄悄放宽成更多数据,与页签语义不符。
if (conditions.some((condition) => !itemOf(condition.field))) continue
views.push({
code: String(view.code),
label: String(view.label ?? view.code),
order: Number(view.order) || (index + 1) * 10,
showCount: view.showCount === true,
conditions,
})
}
views.sort((a, b) => a.order - b.order)
const keywordNode = querySchema?.keywordSearch || null
const keywordItems = (keywordNode?.fields || []).map(itemOf).filter(Boolean)
const keywordSearch = keywordItems.length
? {
placeholder: String(keywordNode.placeholder || ''),
trigger: keywordNode.trigger === 'debounce' ? 'debounce' : 'enter_or_debounce',
items: keywordItems,
}
: null
const quickFilters = (querySchema?.quickFilters || [])
.map((entry, index) => {
const item = itemOf(entry?.field)
if (!item) return null
return { ...item, label: entry.label ?? null, order: Number(entry.order) || (index + 1) * 10 }
})
.filter(Boolean)
.toSorted((a, b) => a.order - b.order)
// 已进快捷筛选的字段不再进更多筛选:同一条条件不给两个入口
const quickFieldNames = new Set(quickFilters.map((item) => String(item.field.b_field)))
const filterGroups = (querySchema?.filterGroups || [])
.map((group, index) => ({
code: String(group?.code ?? `group${index + 1}`),
label: String(group?.label ?? ''),
order: Number(group?.order) || (index + 1) * 10,
items: (group?.fields || [])
.map(itemOf)
.filter((item) => item && !quickFieldNames.has(String(item.field.b_field))),
}))
.filter((group) => group.items.length)
.toSorted((a, b) => a.order - b.order)
return {
views,
defaultViewCode: String(querySchema?.defaultViewCode ?? views[0]?.code ?? ''),
keywordSearch,
quickFilters,
filterGroups,
}
}
/** 节点(含分组子树)在 settings 顺序中的最小序号,用于同层稳定排序 */
@@ -200,39 +276,3 @@ export function applyColumnSettings(schemaType, schema, settings) {
const base = schema || parseSchema(schemaType)
return { ...base, [rootKey]: patch(base[rootKey] || []) }
}
/**
* 查询布局 → 回写 query JSON(「保存为通用」把当前布局持久化为系统默认)。
* 只更新 quick 顺序并标记隐藏字段(condition.area = 'hidden'),保留算子/选项等其余配置;
* 未在布局中出现的字段(如当前用户无权限字段)保持原样。
* @param querySchema 当前 query JSON 对象
* @param layout { area: { [b_field]: 'quick'|'advanced'|'hidden' }, order: { [b_field]: number } }
*/
export function applyQueryLayout(querySchema, layout) {
const area = layout?.area || {}
const order = layout?.order || {}
const hidden = new Set(Object.entries(area).filter(([, v]) => v === 'hidden').map(([field]) => field))
// 启用字段按 order 升序作为 quick(quick 即系统默认的取用顺序)
const quick = Object.keys(area)
.filter((field) => !hidden.has(field))
.toSorted((a, b) => (Number(order[a]) || 0) - (Number(order[b]) || 0))
const base = querySchema || parseSchema('query')
const groups = (base.groups || []).map((group) => {
const conditions = (group.conditions || []).map((cond) => {
if (cond.field == null) return cond
const field = String(cond.field)
if (hidden.has(field)) {
return cond.area === 'hidden' ? cond : { ...cond, area: 'hidden' }
}
// 启用的字段去掉隐藏标记(buildDefaultQueryLayout 仅识别 area === 'hidden')
if (cond.area != null) {
const next = { ...cond }
delete next.area
return next
}
return cond
})
return { ...group, conditions }
})
return { ...base, quick, groups }
}
@@ -0,0 +1,105 @@
<script setup>
/**
* 条件回显:把当前生效的查询条件逐条列出来,每条可单独删除,末尾可一键清除全部。
* 只渲染页面传进来的标签,不判断条件来源(页签条件不在此列 —— 页签本身就是它的控制器)。
*/
import { X } from '@lucide/vue'
import { useI18nStore } from '@/stores/i18n'
defineProps({
// [{ key, text }]:key 由页面定义(决定「删除这一条」要清哪一项)
pills: { type: Array, default: () => [] },
})
const emit = defineEmits(['remove', 'clear'])
const { t } = useI18nStore()
</script>
<template>
<div v-if="pills.length" class="fms-active-filters">
<span
v-for="pill in pills"
:key="pill.key"
class="active-filter-pill"
:title="pill.text"
>
<span class="active-filter-pill__text">{{ pill.text }}</span>
<button
type="button"
class="active-filter-pill__remove"
:aria-label="t('common.removeCondition', '移除条件')"
@click="emit('remove', pill.key)"
>
<X :size="12" />
</button>
</span>
<button type="button" class="active-filter-clear" @click="emit('clear')">
{{ t('common.clearAll', '清除全部') }}
</button>
</div>
</template>
<style scoped lang="scss">
.fms-active-filters {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.active-filter-pill {
display: inline-flex;
max-width: 260px;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 4px 0 10px;
border: 1px solid var(--fms-border);
border-radius: 12px;
background: var(--fms-secondary-hover);
color: var(--fms-text);
font-size: 12px;
line-height: 1;
}
.active-filter-pill__text {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.active-filter-pill__remove {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--fms-text-tertiary);
cursor: pointer;
&:hover {
background: color-mix(in srgb, var(--fms-danger) 12%, transparent);
color: var(--fms-danger);
}
}
.active-filter-clear {
padding: 0 4px;
border: 0;
background: transparent;
color: var(--fms-primary);
font-family: inherit;
font-size: 12px;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
</style>
@@ -1,596 +0,0 @@
<script setup>
import { computed } from 'vue'
import { Plus } from '@lucide/vue'
import { Button, Drawer, Input, Select } from '@/components/ui'
import FmsQueryControl from './FmsQueryControl.vue'
import {
describeAdvancedQuery,
emptyConditionValue,
hasConditionValue,
isDateQuery,
isRangeQuery,
logicLabel,
operatorShortLabel,
operatorsForField,
queryItemLabel,
} from '@/components/fms-module-common/queryUtils'
import { useI18nStore } from '@/stores/i18n'
/**
* 高级查询抽屉(右侧滑出):扁平条件行 + 行间「且/或」连接符。
* 每个条件行 / 条件组自带 join(与上一相邻项的连接方式),首项 join 忽略,
* 因此同层可混用 且/或(如 a 且 b 或 c = (a 且 b) 或 c),无需分组再套一层。
* 条件组只是把若干行收进一个浅底块(视觉归类),组内连接同样由各行的 join 决定。
* 底部实时生成中文条件预览,应用前可确认拼出的条件;「应用」才交给页面执行,
* 关闭时是否回退常用条件由页面决定。
*/
const props = defineProps({
// 抽屉开关
open: { type: Boolean, default: false },
// 可参与高级查询的条件项(已按字段权限裁剪、已排除隐藏字段)
items: { type: Array, default: () => [] },
// { nodes: [{ id, at, join, field, operator, value }], groups: [{ id, at, join, nodes: [...] }] }
modelValue: { type: Object, required: true },
})
const emit = defineEmits(['update:modelValue', 'update:open', 'apply', 'close'])
const { t } = useI18nStore()
// 本层可连接的项数(条件行 + 条件组):只有 ≥2 项时「且/或」才有意义
const layerCount = computed(() => props.modelValue.nodes.length + props.modelValue.groups.length)
// 根上的子项按加入时间排序展示:新增的条件 / 组永远排在最后,紧挨下方的新增按钮。
// nodes 与 groups 是两个数组,展示顺序与数组顺序解耦;逻辑求值只看层逻辑,顺序无关。
const rootChildren = computed(() => {
const state = props.modelValue
const items = [
...state.nodes.map((node) => ({ kind: 'node', id: node.id, at: node.at ?? 0, node })),
...state.groups.map((group) => ({ kind: 'group', id: group.id, at: group.at ?? 0, group })),
]
items.sort((a, b) => a.at - b.at)
return items
})
/** 无障碍标签用完整短语,可见文字用短连接符 */
function logicAria(logic) {
return logic === 'or' ? t('common.matchAny', '任一满足') : t('common.matchAll', '全部满足')
}
/** 根层子项(条件行 / 条件组)的 join:本项与上一相邻项的连接方式 */
function joinOf(child) {
return (child.kind === 'node' ? child.node : child.group).join
}
/** 点击根层行间连接符:切换后一项的 join */
function toggleJoin(child) {
const target = child.kind === 'node' ? child.node : child.group
target.join = target.join === 'or' ? 'and' : 'or'
}
/** 点击组内行间连接符:切换后一行的 join */
function toggleNodeJoin(node) {
node.join = node.join === 'or' ? 'and' : 'or'
}
const itemByField = computed(() => new Map(props.items.map((item) => [item.field.b_field, item])))
const fieldOptions = computed(() =>
props.items.map((item) => ({ value: item.field.b_field, label: queryItemLabel(item, t) })),
)
const conditionCount = computed(() => {
const state = props.modelValue
return (
filledCount(state.nodes) +
(state.groups || []).reduce((total, group) => total + filledCount(group.nodes), 0)
)
})
function operatorOptions(field) {
return operatorsForField(field).map((value) => ({ value, label: operatorShortLabel(value, t) }))
}
function addNodeToGroup(group) {
group.nodes.push(createNode())
}
function operatorOptionsOf(node) {
return operatorOptions(itemByField.value.get(node.field)?.field)
}
/** 条件值控件需要的 item:操作符决定控件形态,其余沿用模块查询配置 */
function controlItemOf(node) {
const item = itemByField.value.get(node.field)
if (!item) return null
return {
field: item.field,
options: item.options,
config: { ...item.config, b_operator: node.operator },
}
}
/** 空值判断类操作符不需要值控件;字段未选时渲染禁用占位 */
function needsValueControl(node) {
return node.operator !== 'is_null' && node.operator !== 'is_not_null'
}
/** 区间 / 日期 / 多选的值控件有自身最小宽度(日期控件 240px),窄栏放不下:值在行内换行占满 */
function needsWideValue(node) {
if (!needsValueControl(node)) return false
const item = controlItemOf(node)
if (!item) return false
return (
isRangeQuery(item) ||
isDateQuery(item) ||
item.config.b_operator === 'in' ||
item.config.b_operator === 'not_in'
)
}
function createNode() {
// 新行字段留空:未选字段前操作符与值控件保持禁用,选中字段后自动激活
return {
id: `${Date.now()}-${Math.random()}`,
at: Date.now(),
join: 'and',
field: '',
operator: 'eq',
value: emptyConditionValue(undefined, 'eq'),
}
}
function addNode() {
props.modelValue.nodes.push(createNode())
}
function addGroup() {
// 组自带 join(与根层上一相邻项连接,默认且),组内各行的连接由行内连接符决定
props.modelValue.groups.push({
id: `${Date.now()}-${Math.random()}`,
at: Date.now(),
join: 'and',
nodes: [createNode()],
})
}
function removeNode(id) {
const state = props.modelValue
const index = state.nodes.findIndex((node) => node.id === id)
if (index >= 0) {
state.nodes.splice(index, 1)
return
}
for (const group of state.groups) {
const groupIndex = group.nodes.findIndex((node) => node.id === id)
if (groupIndex >= 0) {
group.nodes.splice(groupIndex, 1)
if (!group.nodes.length) state.groups.splice(state.groups.indexOf(group), 1)
return
}
}
}
function onFieldChange(node) {
// 切换字段:操作符按新字段类型重置;清空字段则回到禁用占位(eq + 无值)
const field = itemByField.value.get(node.field)?.field
node.operator = field ? operatorsForField(field)[0] : 'eq'
node.value = emptyConditionValue(field, node.operator)
}
function onOperatorChange(node) {
node.value = emptyConditionValue(itemByField.value.get(node.field)?.field, node.operator)
}
function removeGroup(group) {
const groups = props.modelValue.groups
const index = groups.indexOf(group)
if (index >= 0) groups.splice(index, 1)
}
function clearAll() {
const state = props.modelValue
state.nodes.splice(0, state.nodes.length)
state.groups.splice(0, state.groups.length)
}
// 遮罩 / 关闭按钮 / 取消 共用一个出口:条件不落地,是否回退常用条件由页面决定
function close() {
emit('update:open', false)
emit('close')
}
// ── 条件预览(中文描述,应用前确认拼出的条件)──
function filledCount(nodes) {
return (nodes || []).filter((node) => hasConditionValue(node.value)).length
}
const previewText = computed(() => describeAdvancedQuery(props.modelValue, itemByField.value, t))
</script>
<template>
<!-- 高级查询抽屉:复用 ui Drawer,遮罩 / Esc / 焦点 / 滚动锁定 / 边缘缩放均由组件接管 -->
<Drawer
:open="open"
:title="t('common.advancedQuery', '高级查询')"
placement="right"
width="min(600px, 96%)"
@update:open="emit('update:open', $event)"
@cancel="close"
>
<!-- 条件列表:根层子项(条件行 + 条件组)混排,行间是「且/或」连接符 -->
<div v-if="layerCount" class="filter-list">
<template v-for="(child, index) in rootChildren" :key="child.id">
<!-- 相邻子项之间的连接符:切换「后一项」的 join(首项无需连接符) -->
<div v-if="index > 0" class="filter-joiner">
<button
type="button"
class="joiner-pill"
:aria-label="logicAria(joinOf(child))"
@click="toggleJoin(child)"
>
{{ logicLabel(joinOf(child), t) }}
</button>
</div>
<!-- 直接挂在根上的条件行 -->
<div
v-if="child.kind === 'node'"
class="filter-row"
:class="{ 'filter-row--wide-value': needsWideValue(child.node) }"
>
<Select
v-model="child.node.field"
class="filter-row__field"
:options="fieldOptions"
clearable
@update:model-value="onFieldChange(child.node)"
/>
<Select
v-model="child.node.operator"
class="filter-row__operator"
:options="operatorOptionsOf(child.node)"
:filterable="false"
:disabled="!child.node.field"
@update:model-value="onOperatorChange(child.node)"
/>
<FmsQueryControl
v-if="needsValueControl(child.node) && controlItemOf(child.node)"
v-model="child.node.value"
class="filter-row__value"
:item="controlItemOf(child.node)"
:disabled="!child.node.field"
compact
/>
<Input
v-else-if="needsValueControl(child.node)"
v-model="child.node.value"
class="filter-row__value"
disabled
/>
<button
type="button"
class="filter-row__remove"
:aria-label="t('common.remove', '移除')"
@click="removeNode(child.node.id)"
>
×
</button>
</div>
<!-- 条件组:浅底圆角块,无独立头部;底栏承载「添加条件 + 计数 + 删除组」 -->
<div v-else class="filter-group">
<div class="filter-group__body">
<template v-for="(node, nodeIndex) in child.group.nodes" :key="node.id">
<!-- 组内相邻行之间的连接符:切换「后一行」的 join -->
<div v-if="nodeIndex > 0" class="filter-joiner">
<button
type="button"
class="joiner-pill"
:aria-label="logicAria(node.join)"
@click="toggleNodeJoin(node)"
>
{{ logicLabel(node.join, t) }}
</button>
</div>
<div class="filter-row" :class="{ 'filter-row--wide-value': needsWideValue(node) }">
<Select
v-model="node.field"
class="filter-row__field"
:options="fieldOptions"
clearable
@update:model-value="onFieldChange(node)"
/>
<Select
v-model="node.operator"
class="filter-row__operator"
:options="operatorOptionsOf(node)"
:filterable="false"
:disabled="!node.field"
@update:model-value="onOperatorChange(node)"
/>
<FmsQueryControl
v-if="needsValueControl(node) && controlItemOf(node)"
v-model="node.value"
class="filter-row__value"
:item="controlItemOf(node)"
:disabled="!node.field"
compact
/>
<Input
v-else-if="needsValueControl(node)"
v-model="node.value"
class="filter-row__value"
disabled
/>
<button
type="button"
class="filter-row__remove"
:aria-label="t('common.remove', '移除')"
@click="removeNode(node.id)"
>
×
</button>
</div>
</template>
<!-- 组底栏:左「添加条件」、右「删除组」,两个文字按钮,不再有孤立角标 -->
<div class="filter-group__foot">
<button type="button" class="filter-group__add" @click="addNodeToGroup(child.group)">
<Plus :size="12" />{{ t('common.addCondition', '添加条件') }}
</button>
<button
type="button"
class="filter-group__remove"
:aria-label="t('common.removeGroup', '删除条件组')"
@click="removeGroup(child.group)"
>
{{ t('common.removeGroup', '删除组') }}
</button>
</div>
</div>
</div>
</template>
</div>
<!-- 根级操作:挂在整棵列表下方 -->
<div class="filter-actions">
<button type="button" class="filter-add" @click="addNode">
<Plus :size="12" />{{ t('common.condition', '条件') }}
</button>
<button type="button" class="filter-add" @click="addGroup">
<Plus :size="12" />{{ t('common.conditionGroup', '组') }}
</button>
<button type="button" class="filter-add filter-add--muted" @click="clearAll">
{{ t('common.clear', '清空') }}
</button>
</div>
<template #footer>
<div class="filter-foot">
<!-- 实时条件预览:应用前确认拼出的条件 -->
<div class="filter-preview">
<span class="filter-preview__label">{{ t('common.queryPreview', '条件预览') }}</span>
<span class="filter-preview__text" :title="previewText">
{{ previewText || t('common.advancedQueryEmpty', '暂无有效条件') }}
</span>
</div>
<div class="filter-foot-row">
<span class="filter-foot-note">
{{ t('common.conditionCount', '{n} 条条件').replace('{n}', conditionCount) }}
</span>
<div class="filter-foot-actions">
<Button type="outline" @click="close">{{ t('common.cancel', '取消') }}</Button>
<Button type="primary" @click="emit('apply')">{{
t('common.applyQuery', '应用')
}}</Button>
</div>
</div>
</div>
</template>
</Drawer>
</template>
<style scoped lang="scss">
/* 抽屉骨架(遮罩 / 头部 / 内容区 / 底部)统一由 ui Drawer 提供,这里只留业务内容样式 */
/* ---------- 条件列表 ---------- */
.filter-list {
display: flex;
flex-direction: column;
gap: 4px;
}
/* 行间连接符:居中一枚「且/或」药丸即可,不画延长线,弱化纵向分隔 */
.filter-joiner {
display: flex;
flex: none;
align-items: center;
justify-content: center;
padding: 2px 0;
}
/* 逻辑药丸:短连接符 + 悬停染色提示可点;完整短语留给 aria-label */
.joiner-pill {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
min-width: 34px;
height: 22px;
padding: 0 6px;
border: 1px solid var(--fms-border, #dfe5ec);
border-radius: 11px;
background: var(--fms-card, #fff);
color: var(--fms-text-secondary, #6f7784);
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.joiner-pill:hover {
border-color: var(--fms-primary, #0f172b);
background: var(--fms-primary-soft);
color: var(--fms-primary, #0f172b);
}
/* 条件行:字段 / 操作符 / 值,三个独立控件 + 行尾删除;悬停不换底色,保持页面干净 */
.filter-row {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
padding: 5px 6px;
border-radius: var(--fms-control-radius, 6px);
}
/* 三部分各戴各的框,中间留缝:点到哪一段就是哪一段,不存在"点在框上、落在段间" */
.filter-row__field {
flex: 1 1 0;
min-width: 96px;
}
.filter-row__operator {
flex: 0 0 80px;
min-width: 0;
}
.filter-row__value {
flex: 2 1 0;
min-width: 120px;
}
/* 区间 / 日期 / 多选的值控件有自身最小宽度(日期控件 240px):
放不下时值独占一行,其余两段留在上一行 */
.filter-row--wide-value {
flex-wrap: wrap;
}
.filter-row--wide-value .filter-row__value {
flex: 1 0 100%;
}
/* 删除按钮:常驻可见(× 弱灰,悬停转危险色) */
.filter-row__remove {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--fms-text-tertiary, #a3acb8);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.filter-row__remove:hover {
color: var(--fms-danger, #ef4444);
background: color-mix(in srgb, var(--fms-danger) 12%, transparent);
}
/* 条件组:浅底圆角块(无边框),头与条件行同一排版节奏,减少分割线 */
.filter-group {
border-radius: 8px;
background: var(--fms-secondary-hover, #f1f5f9);
overflow: hidden;
}
.filter-group__remove {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--fms-text-tertiary, #a3acb8);
font-size: 12px;
cursor: pointer;
}
.filter-group__remove:hover {
color: var(--fms-danger, #ef4444);
background: color-mix(in srgb, var(--fms-danger) 12%, transparent);
}
.filter-group__body {
padding: 6px;
}
/* 组内行 / 连接符不再自带纵向 padding,由组体统一留白,保证卡片四边间距一致 */
.filter-group__body > .filter-row {
padding: 0 6px;
}
.filter-group__body > .filter-joiner {
padding: 4px 0;
}
/* 组底栏:两端各一个文字按钮,与上一行间隔 4px,水平与行控件对齐 */
.filter-group__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 0 0;
}
/* 组内添加:文字式按钮,无边框,挂在底栏左侧 */
.filter-group__add {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--fms-text-secondary, #6f7784);
font-size: 12px;
cursor: pointer;
}
.filter-group__add:hover {
color: var(--fms-primary, #0f172b);
background: var(--fms-card, #fff);
}
/* 根级操作:轻量文字按钮,挂在整棵列表下方 */
.filter-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
}
.filter-add {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 6px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--fms-primary, #0f172b);
font-size: var(--fms-font-size);
cursor: pointer;
}
.filter-add:hover {
background: var(--fms-secondary-hover, var(--fms-secondary, #e9edf2));
}
.filter-add--muted {
margin-left: auto;
color: var(--fms-text-secondary, #6f7784);
}
/* 底部内容:Drawer 的 footer 是 flex 右对齐,这里撑满整行承载「条件预览 + 计数与操作」 */
.filter-foot {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
min-width: 0;
}
.filter-preview {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
padding: 7px 10px;
border-radius: var(--fms-control-radius, 6px);
background: var(--fms-secondary-hover, #f1f5f9);
}
.filter-preview__label {
flex: none;
color: var(--fms-text-secondary, #6f7784);
font-size: 12px;
}
.filter-preview__text {
min-width: 0;
overflow: hidden;
color: var(--fms-text, #1f2329);
font-size: var(--fms-font-size);
white-space: nowrap;
text-overflow: ellipsis;
}
.filter-foot-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 10px;
}
.filter-foot-note {
color: var(--fms-text-secondary, #6f7784);
font-size: 12px;
}
.filter-foot-actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>
@@ -0,0 +1,284 @@
<script setup>
/**
* 更多筛选(抽屉):按模块配置的字段分组铺开完整筛选字段,控件按字段类型渲染。
* 字段清单、分组、顺序、算子全部来自查询配置 —— 页面与本组件都不内置模块专用选项,
* 也不再提供自由拼装条件行/条件组的入口(技术表达式不由业务人员拼)。
*
* 抽屉内编辑的只是草稿:「应用筛选」才生效并回到第一页,「取消」丢弃改动(由页面重开时重置草稿),
* 「重置」只清空抽屉里的草稿值,同样需要「应用筛选」才落到列表。
*/
import { computed } from 'vue'
import { Button, Drawer, Select } from '@/components/ui'
import FmsQueryControl from './FmsQueryControl.vue'
import {
emptyConditionValue,
hasConditionValue,
isDateQuery,
isRangeQuery,
operatorLabel,
operatorsForField,
queryItemLabel,
} from '@/components/fms-module-common/queryUtils'
import { useI18nStore } from '@/stores/i18n'
const props = defineProps({
open: { type: Boolean, default: false },
// 更多筛选分组:[{ code, label, order, items: queryItem[] }]
groups: { type: Array, default: () => [] },
// 抽屉草稿:{ [b_field]: { operator, value } }
modelValue: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:open', 'update:modelValue', 'apply', 'cancel'])
const { t } = useI18nStore()
/** 条件项在草稿里的当前状态:没有则用配置的算子与空值兜底 */
function entryOf(item) {
const field = String(item.field.b_field)
const entry = props.modelValue?.[field]
if (entry) {
return {
operator: entry.operator || item.config.b_operator,
value:
entry.value === undefined
? emptyConditionValue(item.field, entry.operator || item.config.b_operator)
: entry.value,
}
}
return {
operator: item.config.b_operator,
value: emptyConditionValue(item.field, item.config.b_operator),
}
}
function patch(field, entry) {
emit('update:modelValue', { ...(props.modelValue || {}), [field]: entry })
}
function setOperator(item, operator) {
patch(String(item.field.b_field), {
operator,
value: emptyConditionValue(item.field, operator),
})
}
function setValue(item, value) {
const field = String(item.field.b_field)
patch(field, { ...entryOf(item), value })
}
function operatorOptions(item) {
return operatorsForField(item.field).map((value) => ({
value,
label: operatorLabel(value, t),
}))
}
/** 条件值控件需要的 item:算子决定控件形态,其余沿用模块查询配置 */
function controlItemOf(item) {
return {
field: item.field,
options: item.options,
config: { ...item.config, b_operator: entryOf(item).operator },
}
}
/** 空值判断类算子不需要值控件 */
function needsValueControl(item) {
const operator = entryOf(item).operator
return operator !== 'is_null' && operator !== 'is_not_null'
}
/** 区间 / 日期 / 多选的值控件有自身最小宽度(日期控件 240px),窄栏放不下时值独占一行 */
function needsWideValue(item) {
if (!needsValueControl(item)) return false
const controlItem = controlItemOf(item)
return (
isRangeQuery(controlItem) ||
isDateQuery(controlItem) ||
['in', 'not_in'].includes(controlItem.config.b_operator)
)
}
function labelOf(item) {
return item.label || queryItemLabel(item, t)
}
/** 草稿里已填写的条件数(页脚提示用) */
const draftCount = computed(() =>
flattenItems(props.groups).filter((item) => hasConditionValue(entryOf(item).value)).length,
)
function flattenItems(groups) {
return (groups || []).flatMap((group) => group.items || [])
}
function reset() {
emit('update:modelValue', {})
}
function close() {
emit('update:open', false)
emit('cancel')
}
</script>
<template>
<Drawer
:open="open"
:title="t('common.moreFilters', '更多筛选')"
placement="right"
width="min(600px, 96%)"
@update:open="emit('update:open', $event)"
@cancel="close"
>
<div v-if="groups.length" class="filter-groups">
<section v-for="group in groups" :key="group.code" class="filter-section">
<h4 v-if="group.label" class="filter-section__title">{{ group.label }}</h4>
<div class="filter-section__body">
<div
v-for="item in group.items"
:key="item.field.b_field"
class="filter-row"
:class="{ 'filter-row--wide-value': needsWideValue(item) }"
>
<span class="filter-row__label" :title="labelOf(item)">{{ labelOf(item) }}</span>
<Select
class="filter-row__operator"
:model-value="entryOf(item).operator"
:options="operatorOptions(item)"
:filterable="false"
@update:model-value="setOperator(item, $event)"
/>
<FmsQueryControl
v-if="needsValueControl(item)"
class="filter-row__value"
:model-value="entryOf(item).value"
:item="controlItemOf(item)"
date-presets
compact
@update:model-value="setValue(item, $event)"
/>
<span v-else class="filter-row__value filter-row__value--empty">
{{ t('common.noValueNeeded', '无需填写值') }}
</span>
</div>
</div>
</section>
</div>
<div v-else class="filter-groups-empty">
{{ t('common.noMoreFilters', '本模块暂未配置更多筛选字段') }}
</div>
<template #footer>
<div class="filter-foot">
<span class="filter-foot__note">
{{ t('common.conditionCount', '{n} 条条件').replace('{n}', draftCount) }}
</span>
<div class="filter-foot__actions">
<Button type="ghost" @click="reset">{{ t('common.reset', '重置') }}</Button>
<Button type="outline" @click="close">{{ t('common.cancel', '取消') }}</Button>
<Button type="primary" @click="emit('apply')">
{{ t('common.applyFilter', '应用筛选') }}
</Button>
</div>
</div>
</template>
</Drawer>
</template>
<style scoped lang="scss">
.filter-groups {
display: flex;
flex-direction: column;
gap: 18px;
}
.filter-section__title {
margin: 0 0 6px;
color: var(--fms-text-secondary);
font-size: 12px;
font-weight: 500;
}
.filter-section__body {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 条件行:字段名 / 算子 / 值,三段对齐;行内不画分割线,靠间距分组 */
.filter-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.filter-row__label {
flex: 0 0 96px;
min-width: 0;
overflow: hidden;
color: var(--fms-text);
font-size: var(--fms-font-size);
white-space: nowrap;
text-overflow: ellipsis;
}
.filter-row__operator {
flex: 0 0 108px;
min-width: 0;
}
.filter-row__value {
flex: 1 1 0;
min-width: 120px;
}
.filter-row__value--empty {
color: var(--fms-text-tertiary);
font-size: 12px;
}
/* 区间 / 日期 / 多选的值控件有自身最小宽度:放不下时值独占一行 */
.filter-row--wide-value {
flex-wrap: wrap;
}
.filter-row--wide-value .filter-row__label {
flex: 0 0 96px;
}
.filter-row--wide-value .filter-row__value {
flex: 1 0 100%;
padding-left: 96px;
box-sizing: border-box;
}
.filter-groups-empty {
padding: 24px 0;
color: var(--fms-text-secondary);
font-size: var(--fms-font-size);
text-align: center;
}
/* Drawer 的 footer 是右对齐 flex,这里撑满整行承载计数与操作 */
.filter-foot {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.filter-foot__note {
color: var(--fms-text-secondary);
font-size: 12px;
}
.filter-foot__actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>
@@ -0,0 +1,82 @@
<script setup>
/**
* 关键词搜索:对模块配置的可搜索字段做跨字段模糊匹配(字段之间取「或」)。
* 不要求用户先选字段,字段清单与占位文案全部来自模块查询配置。
*
* 触发方式由配置给出:
* - enter_or_debounce:回车立即查询,停止输入 400ms 后自动查询(默认)
* - enter:只认回车
* - debounce:只认防抖
* 清空输入即立即查询(不必再按回车),避免留下一个「看不出为什么还是旧结果」的空条件。
*/
import { computed, onBeforeUnmount, watch } from 'vue'
import { Search } from '@lucide/vue'
import { Input } from '@/components/ui'
const DEBOUNCE_MS = 400
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
trigger: { type: String, default: 'enter_or_debounce' },
})
const emit = defineEmits(['update:modelValue', 'search'])
let debounceTimer = null
const enterEnabled = computed(() => props.trigger !== 'debounce')
const debounceEnabled = computed(() => props.trigger !== 'enter')
function clearTimer() {
if (!debounceTimer) return
clearTimeout(debounceTimer)
debounceTimer = null
}
function onInput(value) {
emit('update:modelValue', value)
if (!debounceEnabled.value) return
clearTimer()
debounceTimer = setTimeout(() => emit('search', value), DEBOUNCE_MS)
}
function onEnter() {
if (!enterEnabled.value) return
clearTimer()
emit('search', props.modelValue)
}
// 输入被清空(含 allow-clear 的清除按钮):立即查询,不必等防抖
watch(
() => props.modelValue,
(value, previous) => {
if (String(value ?? '').trim() || !String(previous ?? '').trim()) return
clearTimer()
emit('search', '')
},
)
onBeforeUnmount(clearTimer)
</script>
<template>
<div class="fms-keyword-search">
<Input
:model-value="modelValue"
:placeholder="placeholder"
allow-clear
@update:model-value="onInput"
@keydown.enter="onEnter"
>
<template #prefix><Search :size="14" /></template>
</Input>
</div>
</template>
<style scoped lang="scss">
.fms-keyword-search {
display: flex;
min-width: 0;
max-width: 420px;
}
</style>
@@ -1,7 +1,7 @@
<script setup>
import { computed, h, nextTick, onActivated, reactive, ref, shallowRef, useSlots, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Download, Ellipsis, FileSpreadsheet, RefreshCw, RotateCcw, Save } from '@lucide/vue'
import { Download, Ellipsis, FileSpreadsheet, RefreshCw, RotateCcw, Save, Star } from '@lucide/vue'
import { Button, ButtonGroup, Dropdown, Message, Pagination, Spin, Tag } from '@/components/ui'
import { deleteApi, loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from '@/services/api'
import { exportListApi, loadTotalsApi } from '@/services/exportService'
@@ -9,44 +9,45 @@ import { useI18nStore } from '@/stores/i18n'
import {
identifierRe,
astToSql,
buildAdvancedAst,
buildFilterStateAst,
buildKeywordAst,
buildQuickAst,
buildViewAst,
combineAst,
defaultQueryValue,
describeAdvancedQuery,
describeConditionText,
emptyConditionValue,
escapeSqlText,
hasConditionValue,
} from '@/components/fms-module-common/queryUtils'
import { parseOptions } from '@/components/fms-module-common/fieldUtils'
import {
loadModuleSchemas,
buildListConfig,
buildEditRender,
buildQueryRender,
orderQueryConditions,
applyColumnSettings,
applyQueryLayout,
} from '@/components/fms-module-common/schemaRender'
import { stableStringify } from '@/utils/schemaJson'
import FmsModuleTable from './FmsModuleTable.vue'
import FmsQueryToolbar from './FmsQueryToolbar.vue'
import FmsAdvancedQueryPanel from './FmsAdvancedQueryPanel.vue'
import FmsQuerySettingsDrawer from './FmsQuerySettingsDrawer.vue'
import FmsWorkQueueTabs from './FmsWorkQueueTabs.vue'
import FmsKeywordSearch from './FmsKeywordSearch.vue'
import FmsQuickFilters from './FmsQuickFilters.vue'
import FmsFilterDrawer from './FmsFilterDrawer.vue'
import FmsActiveFilterPills from './FmsActiveFilterPills.vue'
import FmsExportDialog from './FmsExportDialog.vue'
import {
PREF_SCHEMA_TYPES,
buildDefaultQueryLayout,
columnSettingsDelta,
currentUserId,
isEmptyColumnDelta,
isEmptyDelta,
layoutDelta,
loadFieldPower,
loadModulePref,
mergeColumnPref,
mergeQueryLayout,
mergeDefaultViewCode,
removeModulePref,
sameColumnSettings,
saveModulePref,
viewCodeDelta,
} from './useModulePref'
import { usePermissionStore } from '@/stores/permissions'
@@ -94,34 +95,44 @@ const activeFields = shallowRef([])
const activeFieldById = shallowRef(new Map())
const listConfig = shallowRef([])
const editConfigById = shallowRef(new Map())
const queryItems = shallowRef([])
// 界面配置 JSON(s_module_schema):栏位设置回写时使用
const viewSchema = shallowRef(null)
const editSchema = shallowRef(null)
const schemaExisted = shallowRef({ view: false, edit: false, query: false })
const dataSource = shallowRef([])
const grandTotals = shallowRef({})
// 当前生效的查询条件 AST(常用 / 高级共用一份结构,经 astToSql 生成 SQL)
// 常用与高级互斥:同一时刻只有一种模式的条件参与查询,切换模式即切换生效条件。
// ── 查询入口状态 ──
// 查询入口配置(s_module_schema.query,schemaVersion 2):页签 / 关键词 / 快捷筛选 / 更多筛选分组
const queryRender = shallowRef({
views: [],
defaultViewCode: '',
keywordSearch: null,
quickFilters: [],
filterGroups: [],
})
// 当前页签(工作队列视图)
const viewCode = ref('')
// 模块配置的默认页签(系统默认层)
const defaultViewCode = ref('')
const keyword = ref('')
// 快捷筛选:草稿(界面绑定)与生效值分开,点「查询」才落到生效
const quickValues = ref({})
const appliedQuick = ref({})
// 更多筛选:草稿(抽屉绑定)与生效值分开,点「应用筛选」才落到生效
const filterDraft = ref({})
const appliedFilters = ref({})
const filterOpen = ref(false)
// 页签数量(懒加载;取不到就不显示徽标,不阻塞列表)
const tabCounts = shallowRef({})
const requestedCountCodes = ref([])
let countsToken = 0
// 当前生效的查询条件 AST(页签 + 关键词 + 快捷筛选 + 更多筛选,经 astToSql 生成 SQL)
const queryAst = shallowRef(null)
// 模块默认条件(常用区字段的 defaultValue),重置时回到这里
const defaultQueryAst = shallowRef(null)
// 最近一次常用查询的条件,从高级模式返回时恢复(保证两种模式不会同时生效)
const quickAst = shallowRef(null)
const queryMode = ref('quick')
// 高级查询抽屉的开关:打开只是进入编辑,「应用」才决定条件是否生效
const advancedOpen = ref(false)
// 查询设置抽屉(页面级:常用模式与高级模式都从这里打开)
const settingsOpen = ref(false)
// 高级查询条件:顶层节点 + 条件组,每项自带 join(与上一项的连接方式)
const advancedQuery = ref({ nodes: [], groups: [] })
// 用户查询布局偏好(只记布局:字段所在区域与顺序,不记条件值)
// 个人层默认页签偏好(s_user_module_pref, b_schema_type='query')
const prefRowExists = ref(false)
const queryLayout = ref({ area: {}, order: {} })
// 系统默认布局(来自 s_module_schema.query;「保存为通用」会更新它)
const defaultQueryLayout = ref({ area: {}, order: {} })
// query 界面配置 JSON(s_module_schema.query),「保存为通用」时回写
const querySchema = shallowRef(null)
const savedDefaultViewCode = ref('')
// ── 栏位设置(列宽/顺序)──
// 基线:进入本页(或保存/重载)时的栏位快照,用于判断「列是否已被调整」。
// 与 tableRef.getColumnSettings() 的当前值逐项比对,决定工具条上「已调整」操作条是否出现。
@@ -203,8 +214,6 @@ function withRevealedInOrder(pref, visibleRows, revealedRows) {
}
// 「保存为通用」会改到所有人的默认视图,限系统管理员(与 /module/* 的 meta.adminOnly 同一判据)
const canSaveColumnGlobal = computed(() => permissions.isG3soft)
// 无查询权限的字段(s_user_field_power.b_query = 0),设置抽屉中灰显
const disabledFields = shallowRef([])
const keyField = ref('')
// 可编辑表格:脏行追踪(rowKey -> Set<已修改字段>),仅在 editable 模式下使用。
const dirtyRows = shallowRef(new Map())
@@ -243,6 +252,12 @@ const effectiveRowDraggable = computed(
// ── 外部工具 ──
// 栏位设置不在此菜单:列宽/顺序改动后由工具条按需浮出操作条承载(见 columnDirty)
const moreMenuItems = computed(() => [
{
key: 'set-default-view',
label: t('common.setDefaultView', '将当前页签设为我的默认'),
icon: h(Star, { size: 14 }),
disabled: !dataModule.value || !viewCode.value,
},
{
key: 'custom-export',
label: t('common.customExport', '自定义导出'),
@@ -256,19 +271,66 @@ const moreMenuItems = computed(() => [
},
])
const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.startsWith('cell-')))
// 高级面板可选字段:排除用户在设置中隐藏的字段
const advancedItems = computed(() =>
queryItems.value.filter(
(item) => (queryLayout.value.area[item.field.b_field] || 'advanced') !== 'hidden',
),
)
// 高级条件生效时的预览:字段编码 → 条件项,与高级面板同一份可选字段集
const advancedItemByField = computed(
() => new Map(advancedItems.value.map((item) => [item.field.b_field, item])),
)
const advancedPreviewText = computed(() =>
describeAdvancedQuery(advancedQuery.value, advancedItemByField.value, t),
)
// ── 查询入口派生 ──
const views = computed(() => queryRender.value.views)
const keywordConfig = computed(() => queryRender.value.keywordSearch)
const quickItems = computed(() => queryRender.value.quickFilters)
const filterGroups = computed(() => queryRender.value.filterGroups)
// 字段编码 → 查询条件项:条件回显文案与更多筛选都从这里取字段元数据
const queryItemByField = computed(() => {
const map = new Map()
const push = (item) => map.set(String(item.field.b_field), item)
for (const item of quickItems.value) push(item)
for (const item of keywordConfig.value?.items || []) push(item)
for (const group of filterGroups.value) {
for (const item of group.items || []) push(item)
}
return map
})
/** 已填写的条件数(草稿与生效值都可能是 0) */
function countFilled(state) {
return Object.values(state || {}).filter((entry) => hasConditionValue(entry?.value)).length
}
/** 页签数量请求条件:与列表同一份条件,只把页签换成目标页签 */
const appliedFilterCount = computed(() => countFilled(appliedFilters.value))
// 工具条中间状态区:更多筛选生效时点明当前生效范围,否则表格看不出条件来自哪里
const statusText = computed(() => {
const parts = []
if (appliedFilterCount.value) parts.push(t('common.moreFiltersActive', '更多筛选生效'))
if (selectedText.value) parts.push(selectedText.value)
return parts.join(' · ')
})
const PILL_KEYWORD = 'keyword'
// 生效条件回显:页签条件不在此列 —— 页签本身就是它的控制器,再列一条只会重复
const activePills = computed(() => {
const pills = []
const items = queryItemByField.value
const keywordText = String(keyword.value || '').trim()
if (keywordText) {
pills.push({ key: PILL_KEYWORD, text: `${t('common.keyword', '关键词')} ${keywordText}` })
}
for (const item of quickItems.value) {
const field = String(item.field.b_field)
const value = appliedQuick.value[field]
if (!hasConditionValue(value)) continue
const text = describeConditionText(
{ field, operator: item.config.b_operator, value },
items,
t,
)
if (text) pills.push({ key: `quick:${field}`, text })
}
for (const [field, entry] of Object.entries(appliedFilters.value)) {
if (!hasConditionValue(entry?.value)) continue
const text = describeConditionText({ field, operator: entry.operator, value: entry.value }, items, t)
if (text) pills.push({ key: `filter:${field}`, text })
}
return pills
})
// 工具条中间状态区:有选中行时提示,无选中时留空不抢视觉
const selectedCount = computed(() => {
const keys = props.rowSelection?.selectedRowKeys
@@ -282,23 +344,6 @@ const selectedText = computed(() =>
const totalText = computed(() =>
t('common.totalRecords', '共 {n} 条').replace('{n}', pagination.total),
)
// 工具条中间状态区:高级条件生效时点明当前生效模式,否则表格看不出条件来自哪里
const statusText = computed(() => {
const parts = []
if (queryMode.value === 'advanced') parts.push(t('common.advancedQueryActive', '高级查询生效'))
if (selectedText.value) parts.push(selectedText.value)
return parts.join(' · ')
})
function countNodes(nodes) {
return (nodes || []).filter((node) => hasConditionValue(node.value)).length
}
const advancedCount = computed(() => {
const state = advancedQuery.value
return (
countNodes(state.nodes) +
(state.groups || []).reduce((total, group) => total + countNodes(group.nodes), 0)
)
})
// ── 模块配置加载 ──
function resolveKeyField(module, fields) {
@@ -407,55 +452,20 @@ async function loadModuleConfig() {
// 个人层只叠加宽度与顺序,不增删字段,因此先裁到同一字段集再合并。
columnGlobalRows.value = lists
// 查询条件:query JSON(quick 顺序优先)
const queryRender = buildQueryRender(schemas.query)
const orderedConditions = orderQueryConditions(queryRender.conditions, queryRender.quick)
const queries = []
for (const condition of orderedConditions) {
const field = fieldById.get(condition.field)
if (!field) continue
const optionInfo = parseOptions({ options: condition.options })
queries.push({
config: {
b_field: condition.field,
b_operator: condition.operator,
b_component: condition.component,
b_default_value: condition.defaultValue,
},
field,
optionConfig: optionInfo.optionConfig,
options: optionInfo.options,
})
}
// 两层配置(系统默认 → 个人)+ 字段查询权限;权限在偏好合并之后裁剪,不得放宽权限
// 两层配置(系统默认 → 个人)+ 字段查询权限:查询入口按字段权限裁剪后再交给渲染层,
// 偏好只能在允许的字段与页签里挑,不得放宽权限(开发规范 通用原则 10)
const userId = currentUserId()
// 查询布局(query)与栏位布局(view)各有一份个人层增量,互不影响
const [savedPref, savedColumnPrefDelta] = await Promise.all([
const [savedPref, savedColumnPrefDelta, fieldPower] = await Promise.all([
loadModulePref(userId, dataId),
loadModulePref(userId, dataId, PREF_SCHEMA_TYPES.view),
loadFieldPower(userId, dataId),
])
const fieldPower = await loadFieldPower(userId, dataId)
if (getDataCode() !== code) return false
const allowedFields = new Set(queries.map((item) => String(item.field.b_field)))
const deniedFields = []
const allowedQueries = []
for (const item of queries) {
const field = String(item.field.b_field)
if (fieldPower.get(field) === 0) {
deniedFields.push(item.field)
allowedFields.delete(field)
continue
}
allowedQueries.push(item)
}
disabledFields.value = deniedFields
prefRowExists.value = Boolean(savedPref)
querySchema.value = schemas.query
// 系统默认 → 个人层覆盖得到最终布局
defaultQueryLayout.value = buildDefaultQueryLayout(allowedQueries, queryRender.quick)
queryLayout.value = mergeQueryLayout(defaultQueryLayout.value, savedPref, allowedFields)
const allowedFields = new Set(
fields.map((field) => String(field.b_field)).filter((field) => fieldPower.get(field) !== 0),
)
const render = buildQueryRender(schemas.query, fields, allowedFields)
// 栏位:通用(lists)→ 个人层叠加宽度与顺序;个人层不增删字段(通用没有的字段一律忽略)
// 首次渲染带上已揭示的隐藏列(按声明顺序插回);基线只取通用可见列,避免把揭示列固化进栏位设置
@@ -469,21 +479,30 @@ async function loadModuleConfig() {
columnBaseline.value = columnRowsOf(mergeColumnPref(lists, savedColumnPrefDelta))
columnDirty.value = false
// 默认条件取全部启用字段:简单查询与高级查询共用同一批启用字段
const defaultValues = {}
for (const item of allowedQueries) defaultValues[item.field.b_field] = defaultQueryValue(item)
const enabledItems = allowedQueries.filter(
(item) => (queryLayout.value.area[item.field.b_field] || 'advanced') !== 'hidden',
// 查询入口:默认页签(系统默认 ← 个人覆盖)与生效条件。切模块即回到配置的默认页签,
// 个人层只允许挑一个已存在且有权限的页签,不能凭空造页签。
queryRender.value = render
defaultViewCode.value = render.defaultViewCode
savedDefaultViewCode.value = String(savedPref?.defaultViewCode || '')
prefRowExists.value = Boolean(savedPref)
viewCode.value = mergeDefaultViewCode(
render.defaultViewCode,
savedPref,
render.views.map((view) => view.code),
)
defaultQueryAst.value = buildQuickAst(enabledItems, defaultValues)
quickAst.value = defaultQueryAst.value
if (queryMode.value === 'quick') queryAst.value = defaultQueryAst.value
quickValues.value = defaultQuickValues(render.quickFilters)
appliedQuick.value = { ...quickValues.value }
keyword.value = ''
filterDraft.value = {}
appliedFilters.value = {}
tabCounts.value = {}
requestedCountCodes.value = []
queryAst.value = buildQueryAst()
dataModule.value = runtimeData
activeFields.value = fields
activeFieldById.value = fieldById
listConfig.value = mergedLists
queryItems.value = allowedQueries
viewSchema.value = schemas.view
editSchema.value = schemas.edit
schemaExisted.value = schemas.existed
@@ -507,24 +526,60 @@ async function loadModuleConfig() {
function resolveOrderBy() {
if (!dataModule.value) throw new Error('数据模块尚未加载')
const configured = String(dataModule.value.b_order_sql || '').trim()
if (configured) return keyField.value ? `${configured}, ${keyField.value} ASC` : configured
if (configured) return keyField.value ? withTieBreaker(configured, keyField.value) : configured
if (keyField.value) return `${keyField.value} DESC`
return 'b_id DESC'
}
/**
* 列表当前生效的查询条件(固定条件 + 查询 AST)。
* 取数与导出共用这一份,导出「与列表一致」才不是两边各自实现。
* 追加主键用于「相同排序值打破平局」。配置里已经排过主键时不再追加:
* 同一列在 ORDER BY 出现两次,SQL Server 会直接报错。
*/
function buildSearchCondition() {
function withTieBreaker(orderSql, field) {
const sorted = orderSql
.split(',')
.some((part) => part.trim().split(/\s+/)[0].toLowerCase() === field.toLowerCase())
return sorted ? orderSql : `${orderSql}, ${field} ASC`
}
/**
* 指定页签下的完整查询 AST:固定条件之外的四个来源按「且」合并 ——
* 页签预设条件 / 关键词 / 快捷筛选 / 更多筛选。
* 页签条件与用户条件固定 AND,用户永远是在当前工作队列里继续收窄。
*/
function buildQueryAstOf(code) {
const view = views.value.find((item) => item.code === code) || null
return combineAst(
buildViewAst(view, activeFieldById.value),
buildKeywordAst(keyword.value, keywordConfig.value?.items || []),
buildQuickAst(quickItems.value, appliedQuick.value),
buildFilterStateAst(appliedFilters.value, queryItemByField.value),
)
}
/** 当前生效的查询 AST(任何条件变化后重新计算,供 SQL 生成与条件回显共用) */
function buildQueryAst() {
return buildQueryAstOf(viewCode.value)
}
/**
* 查询条件片段(固定条件 + 查询 AST)。
* 取数、合计、导出、页签数量共用这一份,导出「与列表一致」才不是两边各自实现;
* 页签数量按目标页签的条件算,因此「切过去会看到多少条」与数字一致。
*/
function conditionSqlOf(ast) {
const conditions = []
const fixedCondition = String(props.fixedSearchCondition || '').trim()
if (fixedCondition) conditions.push(`(${fixedCondition})`)
const astCondition = astToSql(queryAst.value, activeFieldById.value)
const astCondition = astToSql(ast, activeFieldById.value)
if (astCondition) conditions.push(astCondition)
return conditions.join(' AND ')
}
function buildSearchCondition() {
return conditionSqlOf(queryAst.value)
}
async function fetchData() {
if (!dataModule.value || !dataModule.value.b_view_table) return
@@ -550,6 +605,8 @@ async function fetchData() {
if (getDataCode() !== currentCode) return
dataSource.value = res.data?.rows || []
pagination.total = Number(res.data?.total ?? res.data?.row_count) || 0
// 列表条件已变(或已刷新):页签数量按同一份条件重算,避免徽标与列表对不上
invalidateTabCounts()
void fetchGrandTotals(currentModule, currentCode, requestVersion)
} catch (error) {
if (
@@ -605,20 +662,29 @@ async function initialize() {
activeFieldById.value = new Map()
listConfig.value = []
editConfigById.value = new Map()
queryItems.value = []
queryRender.value = {
views: [],
defaultViewCode: '',
keywordSearch: null,
quickFilters: [],
filterGroups: [],
}
viewCode.value = ''
defaultViewCode.value = ''
savedDefaultViewCode.value = ''
keyword.value = ''
quickValues.value = {}
appliedQuick.value = {}
filterDraft.value = {}
appliedFilters.value = {}
filterOpen.value = false
tabCounts.value = {}
requestedCountCodes.value = []
queryAst.value = null
prefRowExists.value = false
viewSchema.value = null
editSchema.value = null
schemaExisted.value = { view: false, edit: false, query: false }
queryAst.value = null
defaultQueryAst.value = null
quickAst.value = null
queryMode.value = 'quick'
advancedOpen.value = false
advancedQuery.value = { nodes: [], groups: [] }
disabledFields.value = []
prefRowExists.value = false
queryLayout.value = { area: {}, order: {} }
querySchema.value = null
keyField.value = ''
dirtyRows.value = new Map()
newRowKeys.value = new Set()
@@ -634,136 +700,184 @@ async function initialize() {
if (loaded) await fetchData()
}
// 常用查询一旦执行即回到常用模式:高级条件让位,两种模式不会同时生效
function search({ ast }) {
if (loading.value) return
queryMode.value = 'quick'
quickAst.value = ast
queryAst.value = ast
/* ================= 查询入口交互(页签 / 关键词 / 快捷筛选 / 更多筛选) ================= */
/** 快捷筛选的初始值:模块配置的默认查询值(「重置」也回到这里) */
function defaultQuickValues(items) {
const values = {}
for (const item of items || []) values[item.field.b_field] = defaultQueryValue(item)
return values
}
/** 条件变化后的统一出口:重算生效 AST、回到第一页、重新取数 */
function refreshQuery() {
queryAst.value = buildQueryAst()
pagination.current = 1
return fetchData()
}
// 重置回到模块默认条件(查询配置的 defaultValue),而不是清空
/** 快捷筛选「查询」:草稿落为生效值 */
function search() {
if (loading.value) return
appliedQuick.value = { ...quickValues.value }
return refreshQuery()
}
/** 「重置」:快捷筛选回到模块配置的默认值(关键词与更多筛选不动) */
function resetSearch() {
if (loading.value) return
queryMode.value = 'quick'
quickAst.value = defaultQueryAst.value
queryAst.value = defaultQueryAst.value
pagination.current = 1
return fetchData()
quickValues.value = defaultQuickValues(quickItems.value)
appliedQuick.value = { ...quickValues.value }
return refreshQuery()
}
// 打开高级查询抽屉:只进入编辑态,条件仍由常用查询生效,直到点「应用」
function openAdvanced() {
advancedOpen.value = true
}
// 关闭抽屉(取消 / 关闭 / 遮罩):只关闭抽屉,不退出高级查询。
// 退出高级仅能通过「回到普通查询」按钮(清空高级条件并恢复常用),避免误触退出。
function closeAdvanced() {
advancedOpen.value = false
}
function applyAdvancedQuery() {
/** 关键词查询:由输入组件按配置的触发方式调用(回车 / 防抖 / 清空) */
function searchKeyword(value) {
if (loading.value) return
advancedOpen.value = false
queryMode.value = 'advanced'
queryAst.value = buildAdvancedAst(advancedQuery.value)
pagination.current = 1
return fetchData()
keyword.value = value
return refreshQuery()
}
// 高级查询生效中「回到普通查询」:清空高级条件草稿、恢复常用条件并重新查询
function backToQuick() {
/** 切换页签:页签条件与用户条件固定「且」,用户已填条件保留 */
function changeView(code) {
if (loading.value || !code || code === viewCode.value) return
viewCode.value = code
return refreshQuery()
}
/* ── 更多筛选抽屉 ──
* 打开时把生效值拷成草稿:「取消」只需关闭抽屉,草稿下次打开时重新拷贝,
* 因此取消不会影响已应用的查询。 */
function openFilters() {
filterDraft.value = { ...appliedFilters.value }
filterOpen.value = true
}
function applyFilters() {
filterOpen.value = false
if (loading.value) return
advancedQuery.value = { nodes: [], groups: [] }
queryMode.value = 'quick'
queryAst.value = quickAst.value
pagination.current = 1
return fetchData()
appliedFilters.value = { ...filterDraft.value }
return refreshQuery()
}
// 保存为个人:把抽屉草稿相对「系统默认」的改动作为增量写 s_user_module_pref。
// 没有个人改动时不落库(已有则删除),回落到系统默认。
async function savePersonalQueryLayout(draft) {
/* ── 条件回显:单条删除 / 清除全部 ── */
/** 单条条件删除:按回显标签的 key 还原到对应来源 */
function removePill(key) {
if (loading.value) return
if (key === PILL_KEYWORD) {
keyword.value = ''
return refreshQuery()
}
const [source, field] = String(key).split(':')
if (source === 'quick') {
const item = quickItems.value.find((entry) => String(entry.field.b_field) === field)
if (!item) return
const empty = emptyConditionValue(item.field, item.config.b_operator)
quickValues.value = { ...quickValues.value, [field]: empty }
appliedQuick.value = { ...appliedQuick.value, [field]: empty }
return refreshQuery()
}
if (source === 'filter') {
const nextDraft = { ...filterDraft.value }
const nextApplied = { ...appliedFilters.value }
delete nextDraft[field]
delete nextApplied[field]
filterDraft.value = nextDraft
appliedFilters.value = nextApplied
return refreshQuery()
}
}
/** 清除全部:快捷筛选回默认值,关键词与更多筛选清空(页签不动) */
function clearAllConditions() {
if (loading.value) return
quickValues.value = defaultQuickValues(quickItems.value)
appliedQuick.value = { ...quickValues.value }
keyword.value = ''
filterDraft.value = {}
appliedFilters.value = {}
return refreshQuery()
}
/* ── 个人层默认页签(s_user_module_pref, b_schema_type='query') ── */
/** 把当前页签设为「我的默认」;当它已是系统默认时,反而清掉个人覆盖(回落到系统默认) */
async function setDefaultView() {
const userId = currentUserId()
const moduleId = String(dataModule.value?.b_id || '').trim()
if (!moduleId) return
const delta = layoutDelta(draft, defaultQueryLayout.value)
if (!userId || !moduleId || loading.value) return
const delta = viewCodeDelta(viewCode.value, defaultViewCode.value)
try {
if (isEmptyDelta(delta)) {
if (userId && prefRowExists.value) {
if (!delta) {
if (prefRowExists.value) {
await removeModulePref(userId, moduleId)
prefRowExists.value = false
}
savedDefaultViewCode.value = ''
} else {
await saveModulePref(userId, moduleId, delta, prefRowExists.value)
prefRowExists.value = true
savedDefaultViewCode.value = delta.defaultViewCode
}
// 同步当前生效布局,让常用查询区立即反映(保存后抽屉保持打开)
queryLayout.value = { area: { ...draft.area }, order: { ...draft.order } }
Message.success(t('common.querySettingsSaved', '查询设置已保存'))
Message.success(t('common.defaultViewSaved', '已设为我的默认页签'))
} catch (error) {
Message.error(
t('common.querySettingsSaveFailed', '查询设置保存失败:') + (error?.message || error),
)
Message.error(t('common.saveFailed', '保存失败:') + (error?.message || error))
}
}
// 保存为通用:把抽屉草稿回写为系统默认(s_module_schema.query),并清空当前用户个人层。
async function saveGlobalQueryLayout(draft) {
const userId = currentUserId()
const moduleId = String(dataModule.value?.b_id || '').trim()
if (!moduleId || !querySchema.value) return
const nextQuery = applyQueryLayout(querySchema.value, draft)
try {
const row = {
b_module_id: moduleId,
b_schema_type: 'query',
b_schema_json: stableStringify(nextQuery),
b_canuse: 1,
}
const request = { table: 's_module_schema', key_field: 'b_module_id,b_schema_type' }
if (schemaExisted.value?.query) request.updates = [row]
else request.inserts = [row]
await saveObjectApi([request])
querySchema.value = nextQuery
schemaExisted.value = { ...schemaExisted.value, query: true }
// 全局已成为系统默认,个人层清空,当前用户直接看到新默认
if (userId && prefRowExists.value) {
await removeModulePref(userId, moduleId)
prefRowExists.value = false
}
const nextDefault = { area: { ...draft.area }, order: { ...draft.order } }
defaultQueryLayout.value = nextDefault
queryLayout.value = nextDefault
Message.success(t('common.globalQuerySettingsSaved', '已保存为通用默认'))
} catch (error) {
Message.error(
t('common.querySettingsSaveFailed', '查询设置保存失败:') + (error?.message || error),
)
}
/* ── 页签数量(懒加载) ──
* 数量 = 「切到该页签后会看到多少条」,因此与列表共用同一份条件,只替换页签条件。
* 取不到就不显示徽标:数量不是强一致业务数据,不允许它阻塞列表或打断操作。 */
/** 并发上限:页签多时不要一次打出十几个分页请求 */
const COUNT_CONCURRENCY = 4
/** 页签组件报来「需要数量的页签」:并入已请求集合后按需补取 */
function requestTabCounts(codes) {
const merged = [...new Set([...requestedCountCodes.value, ...(codes || [])])]
requestedCountCodes.value = merged
void loadTabCounts(merged)
}
// 恢复默认(恢复通用):删除个人层覆盖,回落到系统默认。这不是新增配置层,只是删覆盖。
async function resetQueryLayout() {
const userId = currentUserId()
const moduleId = String(dataModule.value?.b_id || '').trim()
queryLayout.value = {
area: { ...defaultQueryLayout.value.area },
order: { ...defaultQueryLayout.value.order },
}
if (userId && moduleId && prefRowExists.value) {
try {
await removeModulePref(userId, moduleId)
prefRowExists.value = false
} catch (error) {
Message.error(
t('common.querySettingsSaveFailed', '查询设置保存失败:') + (error?.message || error),
)
/** 列表条件变化后作废数量并重算,避免徽标与列表对不上 */
function invalidateTabCounts() {
countsToken += 1
tabCounts.value = {}
if (requestedCountCodes.value.length) void loadTabCounts(requestedCountCodes.value)
}
async function loadTabCounts(codes) {
const module = dataModule.value
if (!module?.b_view_table) return
const pending = codes.filter((code) => !(code in tabCounts.value))
if (!pending.length) return
const token = ++countsToken
const orderBy = resolveOrderBy()
const next = { ...tabCounts.value }
let cursor = 0
const worker = async () => {
while (cursor < pending.length) {
const code = pending[cursor]
cursor += 1
try {
const res = await pageDataApi({
viewName: module.b_view_table,
orderBy,
searchColumns: [],
searchCondition: conditionSqlOf(buildQueryAstOf(code)),
pageNo: 1,
pageSize: 1,
})
if (token !== countsToken) return
next[code] = Number(res.data?.total ?? res.data?.row_count) || 0
} catch {
// 失败静默降级:该页签不显示数量,不弹提示、不阻塞列表
}
}
}
await Promise.all(Array.from({ length: Math.min(COUNT_CONCURRENCY, pending.length) }, worker))
if (token === countsToken) tabCounts.value = next
}
function changePage(page, pageSize) {
@@ -1221,6 +1335,10 @@ async function persistRowOrder(changedKeys) {
}
async function handleMoreAction({ key }) {
if (key === 'set-default-view') {
await setDefaultView()
return
}
if (key === 'custom-export') {
await openExportDialog()
return
@@ -1410,38 +1528,37 @@ defineExpose({
<template v-else>
<div class="module-main-column">
<!-- 上:查询卡(常用条件);高级查询生效时隐藏表单,改为条件预览 -->
<section v-if="queryItems.length" class="fms-module-card">
<FmsQueryToolbar
v-if="queryMode !== 'advanced'"
:query-items="queryItems"
<!-- 上:查询卡(工作队列页签 + 关键词 + 快捷筛选 + 条件回显),全部由模块查询配置驱动 -->
<section v-if="views.length" class="fms-module-card query-card">
<FmsWorkQueueTabs
:views="views"
:active-code="viewCode"
:counts="tabCounts"
@update:active-code="changeView"
@load-counts="requestTabCounts"
/>
<FmsKeywordSearch
v-if="keywordConfig"
v-model="keyword"
:placeholder="keywordConfig.placeholder"
:trigger="keywordConfig.trigger"
@search="searchKeyword"
/>
<FmsQuickFilters
v-if="quickItems.length"
v-model="quickValues"
:items="quickItems"
:loading="loading"
:advanced-count="advancedCount"
:query-layout="queryLayout"
:advanced-count="appliedFilterCount"
@search="search"
@reset="resetSearch"
@open-advanced="openAdvanced"
@open-settings="settingsOpen = true"
@open-advanced="openFilters"
/>
<FmsActiveFilterPills
:pills="activePills"
@remove="removePill"
@clear="clearAllConditions"
/>
<!-- 高级查询生效条:上方预览生效条件,下方操作按钮(与查询/重置同排法) -->
<div v-else class="advanced-active-bar">
<div class="advanced-active-bar__body">
<span class="advanced-active-bar__label">
{{ t('common.advancedQueryActive', '高级查询生效') }}
</span>
<span class="advanced-active-bar__preview" :title="advancedPreviewText">
{{ advancedPreviewText }}
</span>
</div>
<div class="advanced-active-bar__actions">
<Button type="outline" :disabled="loading" @click="openAdvanced">
{{ t('common.editAdvanced', '编辑') }}
</Button>
<Button type="ghost" :disabled="loading" @click="backToQuick">
{{ t('common.backToQuick', '回到普通查询') }}
</Button>
</div>
</div>
</section>
<!-- 下:表格卡(工具条 + 表格 + 分页),撑满剩余高度 -->
@@ -1565,25 +1682,12 @@ defineExpose({
</section>
</div>
<!-- 高级查询抽屉:编辑条件,只有「应用」才生效;关闭时退回常用条件 -->
<FmsAdvancedQueryPanel
v-model="advancedQuery"
v-model:open="advancedOpen"
:items="advancedItems"
@apply="applyAdvancedQuery"
@close="closeAdvanced"
/>
<!-- 查询设置抽屉:DOM 中排在高级抽屉之后,同层浮层里压在其上 -->
<FmsQuerySettingsDrawer
v-model:open="settingsOpen"
:items="queryItems"
:disabled-fields="disabledFields"
:layout="queryLayout"
:default-layout="defaultQueryLayout"
@apply="savePersonalQueryLayout"
@save-global="saveGlobalQueryLayout"
@reset="resetQueryLayout"
<!-- 更多筛选抽屉:编辑草稿,「应用筛选」才生效;取消不影响已应用的查询 -->
<FmsFilterDrawer
v-model="filterDraft"
v-model:open="filterOpen"
:groups="filterGroups"
@apply="applyFilters"
/>
<!-- 自定义导出:条件沿用列表当前条件,弹窗只负责选列 / 区间 / 分卷 -->
@@ -1630,39 +1734,13 @@ defineExpose({
box-shadow: 0 2px 6px rgb(31 35 41 / 4%);
}
/* 高级查询生效条:替代普通查询表单,上预览生效条件、下操作按钮(同查询/重置排法) */
.advanced-active-bar {
/* 查询卡:页签 / 关键词 / 快捷筛选 / 条件回显纵向排列,卡间 8px 灰缝由父级 gap 提供 */
.query-card {
display: flex;
flex: none;
flex-direction: column;
gap: 10px;
padding: 12px 16px;
}
.advanced-active-bar__body {
display: flex;
min-width: 0;
align-items: baseline;
gap: 10px;
}
.advanced-active-bar__label {
flex: none;
color: var(--fms-text-secondary, #6f7784);
font-size: var(--fms-font-size);
white-space: nowrap;
}
.advanced-active-bar__preview {
min-width: 0;
overflow: hidden;
color: var(--fms-text, #1f2329);
font-size: var(--fms-font-size);
white-space: nowrap;
text-overflow: ellipsis;
}
.advanced-active-bar__actions {
display: flex;
flex: none;
align-items: center;
gap: 8px;
padding: 10px 16px 12px;
}
/* 表格卡:工具条 + 表格 + 分页,撑满剩余高度,内部滚动 */
@@ -1,7 +1,11 @@
<script setup>
import { computed } from 'vue'
import FmsField from '@/components/fms-field/FmsField.vue'
import { useI18nStore } from '@/stores/i18n'
import {
DATE_PRESETS,
datePresetLabel,
isDatePresetValue,
isDateQuery,
isEmptyValue,
isNumericField,
@@ -13,12 +17,28 @@ const props = defineProps({
modelValue: { type: [String, Number, Boolean, Array, Object], default: undefined },
compact: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
// 日期字段提供「今天/本周/本月/近三个月/自定义」预设:快捷筛选与更多筛选用,高级条件行不用
datePresets: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'enter'])
const { t } = useI18nStore()
const isRange = computed(() => isRangeQuery(props.item))
const isNumeric = computed(() => isNumericField(props.item))
const isDate = computed(() => isDateQuery(props.item))
// 日期预设只在日期区间控件上生效:算子为 between/ge/le 时才有区间可言
const showDatePreset = computed(() => props.datePresets && isRange.value && isDate.value)
const datePresetOptions = computed(() => [
...DATE_PRESETS.map((preset) => ({
label: datePresetLabel(preset.value, t),
value: preset.value,
})),
{ label: t('common.datePresetCustom', '自定义'), value: 'custom' },
])
const datePreset = computed({
get: () => (isDatePresetValue(props.modelValue) ? props.modelValue.preset : 'custom'),
set: (next) => emit('update:modelValue', next === 'custom' ? ['', ''] : { preset: next }),
})
// 日期区间两端控件类型随字段业务类型走:datetime 出时间列,date 只到日
const isDateTime = computed(() => props.item.field.b_type === 'datetime')
// datetime 端点用秒级精度:分钟精度下「当天末」表达不出来,结束端默认时间还会被
@@ -80,8 +100,17 @@ function isDateBeforeLimit(valueToCompare) {
</script>
<template>
<!-- 日期预设:语义区间(今天/本周/本月/近三个月)优先,选「自定义」才落到两端日期 -->
<FmsField
v-if="showDatePreset"
v-model="datePreset"
class="fms-query-control"
type="select"
:disabled="disabled"
:control-props="{ options: datePresetOptions, filterable: false }"
/>
<div
v-if="isRange"
v-if="isRange && (!showDatePreset || datePreset === 'custom')"
class="fms-query-control fms-query-control__range"
:class="{ 'fms-query-control__range--compact': compact }"
>
@@ -1,243 +0,0 @@
<script setup>
import { computed, h, ref, watch } from 'vue'
import { Button, Drawer } from '@/components/ui'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { queryItemLabel } from '@/components/fms-module-common/queryUtils'
import { useI18nStore } from '@/stores/i18n'
/**
* 查询设置抽屉:勾选可用于查询的字段,拖拽调整顺序。
* 一行一个字段,一个「启用」勾选框:勾上=可用于查询(简单查询按顺序排布、
* 超出可见条数的收起,高级查询里同样可选),取消=不出现在任何查询区;
* 无查询权限的字段置灰只读。
*
* 抽屉在本地维护一份草稿(draft),打开时从 layout 复制,编辑期间不超过 parent;
* 保存只把草稿通过事件上抛,不关闭抽屉;恢复默认把草稿置回 defaultLayout。
* 发送内容为 { area, order }(增量由父级计算)。
*
* 启用与否只由 area 是否为 hidden 决定(非 hidden 即启用),勾选写 quick、取消写 hidden。
*/
const props = defineProps({
open: { type: Boolean, default: false },
// 可查询条件项(已按字段权限裁剪)
items: { type: Array, default: () => [] },
// 无查询权限的字段(灰显,不可调整)
disabledFields: { type: Array, default: () => [] },
// { area: { [b_field]: 'quick' | 'advanced' | 'hidden' }, order: { [b_field]: number } }
layout: { type: Object, default: () => ({ area: {}, order: {} }) },
// 恢复通用(清空个人配置)的目标布局 = 系统默认布局
defaultLayout: { type: Object, default: () => ({ area: {}, order: {} }) },
})
const emit = defineEmits(['update:open', 'apply', 'reset', 'save-global'])
const { t } = useI18nStore()
/* ---------- 本地草稿:打开时从 layout 复制,编辑期间与父级互不影响 ---------- */
const draft = ref({ area: {}, order: {} })
function cloneLayout(layout) {
return { area: { ...layout?.area }, order: { ...layout?.order } }
}
watch(
() => props.open,
(open) => {
if (open) draft.value = cloneLayout(props.layout)
},
)
function orderOf(field) {
return draft.value.order[field] ?? 999
}
/* ---------- 行数据:一行一个字段,行序即优先级 ---------- */
const rows = computed(() => {
// 有权限的字段按 order 升序:表格行顺序就是它们被取用的先后(简单查询按此顺序排布)
const enabledRows = props.items
.map((item) => {
const key = item.field.b_field
// 非 hidden 即启用;未配置的字段视为启用(与工具条 areaOf 的默认值一致)
const enabled = (draft.value.area[key] ?? 'advanced') !== 'hidden'
return {
key,
label: queryItemLabel(item, t),
enabled: enabled ? 1 : 0,
disabled: false,
}
})
.toSorted((a, b) => orderOf(a.key) - orderOf(b.key))
// 无查询权限的字段只读展示,固定在末尾、不参与排序
const disabled = props.disabledFields.map((field) => ({
key: `disabled:${field.b_field}`,
label: field.b_name || field.b_field,
enabled: 0,
disabled: true,
}))
return [...enabledRows, ...disabled]
})
/* ---------- 启用开关与排序 ---------- */
/** 切换字段是否可用于查询:非 hidden 即启用(启用统一写 quick,与默认布局一致) */
function toggleEnabled(row) {
if (row.disabled) return
draft.value.area[row.key] = row.enabled ? 'hidden' : 'quick'
}
/**
* 拖拽排序:按新行序重写 order(行顺序即优先级顺序)。
* 无查询权限的字段固定在末尾、不参与排序,拖到别处会在下次渲染时回到末尾。
*/
function onRowOrderChange(nextRows) {
nextRows
.filter((row) => !row.disabled)
.forEach((row, index) => {
draft.value.order[row.key] = (index + 1) * 10
})
}
/* ---------- 单元格渲染 ---------- */
const stop = (event) => event.stopPropagation()
/** 启用列:勾上=该字段可用于查询,取消=不出现在查询区 */
function createEnabledCell({ row }) {
return h(
'label',
{
class: ['fms-checkbox', row.enabled ? 'checked' : '', row.disabled ? 'disabled' : ''],
onMousedown: stop,
onClick: stop,
},
[
h('input', {
type: 'checkbox',
checked: Boolean(row.enabled),
disabled: row.disabled,
'aria-label': `${row.label} ${t('common.enabled', '启用')}`,
onChange: () => toggleEnabled(row),
}),
h('span', { class: 'fms-checkbox-box' }),
],
)
}
/** 字段列:无权限的字段附一条标记 */
function createLabelCell({ row }) {
const cells = [h('span', { class: 'field-label', title: row.label }, row.label)]
if (row.disabled) {
cells.push(h('span', { class: 'field-tag' }, t('common.noQueryPermission', '无查询权限')))
}
return cells
}
const columns = computed(() => [
{
title: t('common.field', '字段'),
dataIndex: 'label',
width: 214,
customCell: createLabelCell,
},
{
title: t('common.enabled', '启用'),
dataIndex: 'enabled',
width: 72,
headerAlign: 'center',
customCell: createEnabledCell,
},
])
/* ---------- 抽屉动作 ---------- */
function close() {
emit('update:open', false)
}
// 保存为个人:把草稿上抛父级持久化,抽屉保持打开(不关闭)
function apply() {
emit('apply', cloneLayout(draft.value))
}
// 保存为通用:把草稿上抛父级持久化,抽屉保持打开(不关闭)
function saveGlobal() {
emit('save-global', cloneLayout(draft.value))
}
// 恢复通用:本地草稿回落到系统默认,并通知父级清空个人配置
function resetDefault() {
draft.value = cloneLayout(props.defaultLayout)
emit('reset')
}
</script>
<template>
<!-- 查询设置抽屉:复用 ui Drawer,遮罩 / Esc / 焦点 / 滚动锁定 / 边缘缩放均由组件接管 -->
<Drawer
:open="open"
:title="t('common.querySettings', '查询设置')"
placement="right"
width="min(540px, 96%)"
@update:open="emit('update:open', $event)"
@cancel="close"
>
<!-- 一行一个字段:「启用」勾选决定字段是否可用于查询,拖拽调整优先级
(行序即取用顺序),表格宽度自适应铺满抽屉 -->
<FmsTable
class="settings-table"
:columns="columns"
:data-source="rows"
row-key="key"
auto-fit-width
row-draggable
:bordered="true"
:virtual="false"
:virtual-x="false"
:row-height="36"
@row-order-change="onRowOrderChange"
/>
<template #footer>
<div class="settings-footer">
<Button type="ghost" @click="resetDefault">{{
t('common.restoreDefault', '恢复通用')
}}</Button>
<div class="settings-footer__actions">
<Button type="outline" @click="saveGlobal">{{
t('common.saveAsGlobal', '保存为通用')
}}</Button>
<Button type="primary" @click="apply">{{ t('common.saveAsPersonal', '保存为个人') }}</Button>
</div>
</div>
</template>
</Drawer>
</template>
<style scoped lang="scss">
/* 抽屉骨架(遮罩 / 头部 / 内容区 / 底部)由 ui Drawer 提供,这里只留表格容器 */
.settings-table {
height: 100%;
}
.settings-footer {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.settings-footer__actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>
<!-- 非 scoped:单元格内容由 FmsTable 渲染,不带本组件的 scope 属性,scoped 选择器命不中。
统一用 .settings-table 限定作用域,避免污染其它表格。 -->
<style lang="scss">
.settings-table .field-label {
display: inline-block;
max-width: 100%;
overflow: hidden;
vertical-align: middle;
white-space: nowrap;
text-overflow: ellipsis;
}
.settings-table .field-tag {
margin-left: 6px;
color: var(--fms-disabled-text, #a3acb8);
font-size: 12px;
}
</style>
@@ -1,248 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { ChevronDown, Filter, Search, Settings } from '@lucide/vue'
import { Button, Form, FormItem } from '@/components/ui'
import FmsQueryControl from './FmsQueryControl.vue'
import {
buildQuickAst,
defaultQueryValue,
hasConditionValue,
queryItemLabel,
} from '@/components/fms-module-common/queryUtils'
import { useI18nStore } from '@/stores/i18n'
/**
* 常用查询卡内容:定宽 4 列条件网格,全部启用字段按顺序排布,
* 超出 quickVisibleLimit 的部分收进「展开」。
* 高级查询抽屉由页面渲染(左侧滑出),本组件只负责打开入口;两种模式互斥。
* 条件构造与高级查询共用 buildQuickAst / buildAdvancedAst → astToSql 一条链路。
*/
const props = defineProps({
queryItems: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
// 高级条件已填写数量(角标)
advancedCount: { type: Number, default: 0 },
// { area: { [b_field]: 'quick' | 'advanced' | 'hidden' }, order: { [b_field]: number } }
queryLayout: { type: Object, default: () => ({ area: {}, order: {} }) },
quickVisibleLimit: { type: Number, default: 4 },
})
const emit = defineEmits(['search', 'reset', 'open-advanced', 'open-settings'])
const { t } = useI18nStore()
const filters = reactive({})
const quickExpanded = ref(false)
const areaOf = (item) => props.queryLayout.area?.[item.field.b_field] || 'advanced'
const orderOf = (item) => props.queryLayout.order?.[item.field.b_field] ?? 999
/* 简单查询用全部启用字段:按顺序排在工具栏,超过可见条数的由「展开」放出 */
const quickItems = computed(() =>
props.queryItems
.filter((item) => areaOf(item) !== 'hidden')
.toSorted((a, b) => orderOf(a) - orderOf(b)),
)
const visibleQuickItems = computed(() =>
quickExpanded.value ? quickItems.value : quickItems.value.slice(0, props.quickVisibleLimit),
)
const extraQuickItems = computed(() => quickItems.value.slice(props.quickVisibleLimit))
const extraConditionCount = computed(
() =>
extraQuickItems.value.filter((item) => hasConditionValue(filters[item.field.b_field])).length,
)
/* 条件列宽上限:4 列整行 = 4×288 + 3×16(列间距)= 1200px,不含右侧操作区。
窗口不够宽时列会等比收缩、只是到不了上限;改这里需同步 .query-grid 的兜底值 */
const GRID_MAX_COLUMN_WIDTH = 288
/** 网格列数按实际可见条件数收缩(最多 4 列):只有一个条件时不应占满 4 列宽 */
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${Math.min(Math.max(visibleQuickItems.value.length, 1), 4)}, minmax(0, ${GRID_MAX_COLUMN_WIDTH}px))`,
}))
function initializeDrafts() {
for (const key of Object.keys(filters)) delete filters[key]
for (const item of props.queryItems) {
filters[item.field.b_field] = defaultQueryValue(item)
}
quickExpanded.value = false
}
function labelOf(item) {
return queryItemLabel(item, t)
}
function handleSearch() {
emit('search', { mode: 'quick', ast: buildQuickAst(quickItems.value, filters) })
}
function handleReset() {
initializeDrafts()
emit('reset')
}
watch(() => props.queryItems, initializeDrafts, { immediate: true })
</script>
<template>
<div class="fms-query-toolbar">
<div class="query-card">
<div class="query-layout">
<!-- 条件字段走组件库 Form/FormItem:label 排版与全系统一致(并跟随全局布局配置),
Form 根用 display:contents 透明化,真正参与 .query-grid 网格的是里面的 FormItem -->
<div class="query-grid" :style="gridStyle">
<!-- 横向布局下标签定宽 72px(约 5 个汉字)、右对齐:标签右缘与控件左缘严格对齐,
一行内所有控件起始位置一致(与全系统表单同一观感);
4 列网格每列上限 288px(整行 1200px),控件区因此有 216px;
超长字段名由 .query-field 里的省略号截断 -->
<Form class="query-fields" :model="filters" label-width="72px" label-align="right">
<FormItem
v-for="item in visibleQuickItems"
:key="item.field.b_field"
class="query-field"
:name="item.field.b_field"
:label="labelOf(item)"
>
<FmsQueryControl
v-model="filters[item.field.b_field]"
:item="item"
@enter="handleSearch"
/>
</FormItem>
</Form>
</div>
<div class="query-actions">
<div class="action-group">
<Button type="primary" :loading="loading" @click="handleSearch">
<Search :size="14" />{{ t('common.search', '查询') }}
</Button>
<Button type="outline" :disabled="loading" @click="handleReset">
{{ t('common.reset', '重置') }}
</Button>
</div>
<div class="action-group">
<Button
v-if="extraQuickItems.length"
type="ghost"
class="quick-more-button"
@click="quickExpanded = !quickExpanded"
>
{{ quickExpanded ? t('common.less', '收起') : t('common.expand', '展开') }}
<span v-if="!quickExpanded && extraConditionCount" class="query-count">
{{ extraConditionCount }}
</span>
<ChevronDown :size="14" :class="{ 'is-up': quickExpanded }" />
</Button>
<Button type="ghost" class="advanced-query-button" @click="emit('open-advanced')">
<Filter :size="14" />
{{ t('common.advancedQuery', '高级查询') }}
<span v-if="advancedCount" class="query-count">{{ advancedCount }}</span>
</Button>
<Button type="ghost" class="query-settings-button" @click="emit('open-settings')">
<Settings :size="14" />
{{ t('common.querySettings', '查询设置') }}
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.fms-query-toolbar {
flex: none;
padding: 12px 16px 8px;
}
.query-card {
container-type: inline-size;
}
/* 字段网格在左、操作区紧跟右侧,同一行底部对齐 */
.query-layout {
display: flex;
align-items: flex-end;
gap: 16px;
}
/* 常用条件网格:一行最多 4 个、每列最大 288px(整行 1200px,列数与上限由行内 style 下发,
此处只是兜底);窗口不够宽时网格整体收缩、各列等比变窄,不横向溢出 */
.query-grid {
display: grid;
flex: 0 1 auto;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 288px));
gap: 8px 16px;
align-items: end;
}
/* Form 只作上下文容器:不产生盒模型,子项直接成为 .query-grid 的网格单元 */
.query-fields {
display: contents;
}
.query-field {
min-width: 0;
/* 网格已用 gap 控制间距,去掉 FormItem 自带的下外边距 */
margin-bottom: 0;
gap: 4px;
}
/* 条件标签沿用查询区原有观感:次要色 + 单行省略(组件库默认是主文字色)。
纵向时标签独占一行、占满列宽,不做宽度限制 */
.query-field :deep(.fms-form-item-label) {
overflow: hidden;
color: var(--fms-text-secondary, #6f7784);
white-space: nowrap;
text-overflow: ellipsis;
}
/* 横向:宽度来自 Form 的 label-width="72px"(定宽,短标签也占满);
标签右对齐由 label-align="right" 在 Form 上传入,这里只把标签与控件的间距
从组件库默认 12px 收到 6px,给控件留出更多宽度 */
.query-fields.fms-form-horizontal .query-field :deep(.fms-form-item-label) {
padding-right: 6px;
}
/* 操作区:紧跟字段右侧、排成一行,右对齐;分「执行」「查询方式/配置」两组,中间竖分隔线 */
.query-actions {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.action-group {
display: flex;
align-items: center;
gap: 8px;
}
/* 屏幕不够宽:操作区两组折行(竖排成两行),把横向空间让给 4 列字段 */
@container (max-width: 1080px) {
.query-actions {
flex-direction: column;
align-items: flex-end;
}
}
.quick-more-button,
.advanced-query-button,
.query-settings-button {
gap: 5px;
}
.quick-more-button svg.is-up {
transform: rotate(180deg);
}
/* 高级查询、查询设置同款:ghost + 图标文字 + 次要色,hover 转主色 */
.advanced-query-button,
.query-settings-button {
color: var(--fms-text-secondary, #6f7784);
}
.advanced-query-button:hover,
.query-settings-button:hover {
color: var(--fms-primary, #0f172b);
}
.query-count {
display: inline-grid;
min-width: 18px;
height: 18px;
padding: 0 5px;
place-items: center;
border-radius: 9px;
background: var(--fms-primary-soft);
color: var(--fms-primary, #0f172b);
font-size: 11px;
}
</style>
@@ -0,0 +1,137 @@
<script setup>
/**
* 快捷筛选:展示模块配置的 3~5 个高频条件,控件形态由字段类型决定
* (枚举多选、日期预设区间、文本包含、数值比较),不用统一文本框。
*
* 值是「草稿」,点「查询」才生效:日期与数值的中途输入(如 '2026-0')直接拼进 SQL 会报错,
* 因此保留显式提交;生效条件由页面的条件回显区展示。
*/
import { computed } from 'vue'
import { ChevronDown, Filter, Search } from '@lucide/vue'
import { Button, Form, FormItem } from '@/components/ui'
import FmsQueryControl from './FmsQueryControl.vue'
import { queryItemLabel } from '@/components/fms-module-common/queryUtils'
import { useI18nStore } from '@/stores/i18n'
/** 条件列宽上限:4 列整行 = 4×288 + 3×16(列间距)= 1200px,不含右侧操作区 */
const GRID_MAX_COLUMN_WIDTH = 288
const props = defineProps({
// 快捷筛选条件项(已按配置顺序排序、已按字段权限裁剪)
items: { type: Array, default: () => [] },
// 条件值草稿:{ [b_field]: value }
modelValue: { type: Object, default: () => ({}) },
loading: { type: Boolean, default: false },
// 已生效的更多筛选条件数(角标)
advancedCount: { type: Number, default: 0 },
})
const emit = defineEmits(['update:modelValue', 'search', 'reset', 'open-advanced'])
const { t } = useI18nStore()
const values = computed({
get: () => props.modelValue || {},
set: (next) => emit('update:modelValue', next),
})
/** 网格列数按实际条件数收缩(最多 4 列):只有一两个条件时不应铺满整行 */
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${Math.min(Math.max(props.items.length, 1), 4)}, minmax(0, ${GRID_MAX_COLUMN_WIDTH}px))`,
}))
function labelOf(item) {
return item.label || queryItemLabel(item, t)
}
function setValue(field, value) {
values.value = { ...values.value, [field]: value }
}
function submit() {
if (props.loading) return
emit('search')
}
</script>
<template>
<div class="fms-quick-filters">
<div class="quick-grid" :style="gridStyle">
<Form :model="values" label-width="72px" label-align="right">
<FormItem
v-for="item in items"
:key="item.field.b_field"
class="quick-field"
:name="item.field.b_field"
:label="labelOf(item)"
>
<FmsQueryControl
:model-value="values[item.field.b_field]"
:item="item"
date-presets
@update:model-value="setValue(item.field.b_field, $event)"
@enter="submit"
/>
</FormItem>
</Form>
</div>
<div class="quick-actions">
<Button type="primary" :loading="loading" @click="submit">
<Search :size="14" />{{ t('common.search', '查询') }}
</Button>
<Button type="outline" :disabled="loading" @click="emit('reset')">
{{ t('common.reset', '重置') }}
</Button>
<Button type="ghost" class="quick-more" @click="emit('open-advanced')">
<Filter :size="14" />
{{ t('common.moreFilters', '更多筛选') }}
<span v-if="advancedCount" class="quick-more__count">{{ advancedCount }}</span>
<ChevronDown :size="14" />
</Button>
</div>
</div>
</template>
<style scoped lang="scss">
.fms-quick-filters {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 16px;
}
.quick-grid {
display: grid;
flex: 1;
min-width: 0;
gap: 12px 16px;
}
:deep(.quick-field) {
min-width: 0;
}
.quick-actions {
display: flex;
flex: none;
align-items: center;
gap: 8px;
}
.quick-more {
display: inline-flex;
align-items: center;
gap: 4px;
}
.quick-more__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--fms-primary-soft);
color: var(--fms-primary);
font-size: 12px;
line-height: 1;
}
</style>
@@ -0,0 +1,180 @@
<script setup>
/**
* 工作队列页签:预置查询视图(全部 / 待处理 / 有异常 …),进入页面即可用。
* 页签编码、文案、顺序、默认项、是否显示数量、预设条件全部来自模块查询配置,
* 本组件不含任何模块专用分支,也不自己请求数据。
*
* 数量由页面按需懒加载:首屏只取可见页签,超出部分收进「更多」,展开时才补取。
* 统计失败不阻塞列表 —— 数量为空即不显示徽标,页签仍可切换。
*/
import { computed, ref, watch } from 'vue'
import { ChevronDown } from '@lucide/vue'
import { Dropdown } from '@/components/ui'
import { useI18nStore } from '@/stores/i18n'
const props = defineProps({
// 已排序的页签配置:[{ code, label, order, showCount, conditions }]
views: { type: Array, default: () => [] },
activeCode: { type: String, default: '' },
// 页签编码 → 数量(缺失或非数字表示「暂无数量」)
counts: { type: Object, default: () => ({}) },
// 直接铺开的页签数量上限,其余进「更多」
visibleLimit: { type: Number, default: 6 },
})
const emit = defineEmits(['update:activeCode', 'load-counts'])
const { t } = useI18nStore()
const overflowOpen = ref(false)
const visibleViews = computed(() => props.views.slice(0, props.visibleLimit))
const overflowViews = computed(() => props.views.slice(props.visibleLimit))
const overflowItems = computed(() =>
overflowViews.value.map((view) => ({ key: view.code, label: view.label })),
)
const isOverflowActive = computed(() =>
overflowViews.value.some((view) => view.code === props.activeCode),
)
/** 页签数量:配置未开启统计或还没拿到数据时为 null(不渲染徽标) */
function countOf(view) {
if (!view?.showCount) return null
const value = props.counts?.[view.code]
return Number.isFinite(value) ? value : null
}
/** 只请求需要数量的页签,过滤失败/未开启的 */
function countsOf(views) {
return views.filter((view) => view.showCount).map((view) => view.code)
}
function select(code) {
if (!code || code === props.activeCode) return
emit('update:activeCode', code)
}
function onOverflowClick({ key }) {
select(key)
}
// 首屏只取可见页签的数量;页签配置变化(切换模块)时重新取
watch(
visibleViews,
(views) => {
const codes = countsOf(views)
if (codes.length) emit('load-counts', codes)
},
{ immediate: true },
)
// 「更多」展开时才补取其余页签的数量:不展开就不为看不见的页签发请求
watch(overflowOpen, (open) => {
if (!open) return
const codes = countsOf(overflowViews.value)
if (codes.length) emit('load-counts', codes)
})
</script>
<template>
<div class="fms-work-queue-tabs">
<button
v-for="view in visibleViews"
:key="view.code"
type="button"
class="work-queue-tab"
:class="{ 'is-active': view.code === activeCode }"
@click="select(view.code)"
>
{{ view.label }}
<span v-if="countOf(view) != null" class="work-queue-tab__count">{{ countOf(view) }}</span>
</button>
<Dropdown
v-if="overflowViews.length"
:items="overflowItems"
placement="bottom-start"
@click="onOverflowClick"
@update:open="overflowOpen = $event"
>
<button
type="button"
class="work-queue-tab work-queue-tab--more"
:class="{ 'is-active': isOverflowActive }"
>
{{ t('common.more', '更多') }}
<ChevronDown :size="14" />
</button>
</Dropdown>
</div>
</template>
<style scoped lang="scss">
.fms-work-queue-tabs {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.work-queue-tab {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 15px;
background: transparent;
color: var(--fms-text-secondary);
font-family: inherit;
font-size: var(--fms-font-size);
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition:
background-color 150ms ease,
color 150ms ease;
&:hover {
background: var(--fms-secondary-hover);
color: var(--fms-text);
}
&.is-active {
border-color: var(--fms-primary-soft);
background: var(--fms-primary-soft);
color: var(--fms-primary);
font-weight: 500;
}
}
.work-queue-tab__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--fms-secondary);
color: var(--fms-text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 1;
}
.work-queue-tab.is-active .work-queue-tab__count {
background: var(--fms-surface);
color: var(--fms-primary);
}
.work-queue-tab--more {
color: var(--fms-text-secondary);
}
</style>
@@ -1,6 +1,6 @@
import FmsModuleListPage from './FmsModuleListPage.vue'
import FmsModuleTable from './FmsModuleTable.vue'
import FmsQueryControl from './FmsQueryControl.vue'
import FmsQueryToolbar from './FmsQueryToolbar.vue'
import FmsQuickFilters from './FmsQuickFilters.vue'
export { FmsModuleListPage, FmsModuleTable, FmsQueryControl, FmsQueryToolbar }
export { FmsModuleListPage, FmsModuleTable, FmsQueryControl, FmsQuickFilters }
@@ -1,17 +1,16 @@
// 布局读写与两层继承合并(系统默认 → 个人)。
// s_module_schema —— 系统默认。查询布局以 query JSON(quick 列表 + 条件)为来源;
// 列宽顺序以 view JSON(columns 节点)为来源。
// 分别由 schemaRender.applyQueryLayout / applyColumnSettings 在
// 「保存为通用」时回写。
// s_module_schema —— 系统默认。查询入口以 query JSON(页签 + 关键词 + 快捷筛选 +
// 更多筛选分组 + 字段算子)为来源;列宽顺序以 view JSON(columns 节点)
// 为来源。query 布局由模块管理页维护,「保存为通用」不算布局调整。
// s_user_module_pref —— 个人覆盖(b_schema_type + b_schema_json),只存相对系统默认的增量。
// 两种布局(query 查询布局 / view 栏位布局)共用本文件的读写与增量计算,靠 schemaType 区分。
// 布局只能调整区域、顺序与列宽,不得放宽字段权限——合并后仍按字段权限裁剪(开发规范 通用原则 10)。
// 两类偏好(query 默认页签 / view 栏位布局)共用本文件的读写与增量计算,靠 schemaType 区分。
// 偏好只能调整默认页签、列顺序与列宽,不得放宽字段权限——合并后仍按字段权限裁剪(开发规范 通用原则 10)。
import { loadDataApi, saveObjectApi } from '@/services/api'
import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
import { formatLocalDateTime } from '@/components/fms-module-common/fieldUtils'
import { useAuthStore } from '@/stores/auth'
/** 个人偏好层可记录的布局类型:query=查询布局,view=列表栏位布局 */
/** 个人偏好层可记录的布局类型:query=默认页签,view=列表栏位布局 */
export const PREF_SCHEMA_TYPES = { query: 'query', view: 'view' }
const DEFAULT_SCHEMA_TYPE = PREF_SCHEMA_TYPES.query
const USER_PREF_TABLE = 's_user_module_pref'
@@ -24,77 +23,25 @@ export function currentUserId() {
}
/**
* 模块默认查询布局。启用与否只由「启用」开关决定(area 非 hidden 即启用):
* 1. 查询 JSON 节点上显式配 hidden 的字段默认不启用;其余(含历史数据里的
* quick / advanced)都按启用处理,简单查询按顺序取前几条、其余由「展开」收起;
* 2. 模块配置的 quick 列表只用来定顺序:列表内的字段排在前面,其余按查询条件顺序。
* 个人默认页签 → 增量覆盖({ schemaVersion: 1, defaultViewCode })。
* 与系统默认一致时不落库(返回 null):两层继承下「无覆盖」就是跟随系统默认。
*/
export function buildDefaultQueryLayout(queryItems, quick = []) {
const area = {}
const order = {}
const quickRank = new Map(quick.map((field, index) => [String(field), index]))
queryItems.forEach((item, index) => {
const field = String(item.field.b_field)
area[field] = item.node?.area === 'hidden' ? 'hidden' : 'quick'
order[field] = quickRank.has(field) ? (quickRank.get(field) + 1) * 10 : 1000 + (index + 1) * 10
})
return { area, order }
export function viewCodeDelta(currentCode, baseCode) {
const current = String(currentCode ?? '')
if (!current || current === String(baseCode ?? '')) return null
return { schemaVersion: 1, defaultViewCode: current }
}
/**
* 单层覆盖 JSON 的规范化:读取层叠覆盖时取平铺的 { area, order };
* 兼容旧结构 { query: { default: { area, order } } }(新三层模型 JSON 不再用 default/node 表达层级)。
* 个人层默认页签叠加到系统默认之上。
* 个人值必须是本模块当前真实可用的页签编码(页签被删除 / 无权限时自动回落系统默认),
* 偏好只能挑页签,不能凭空造出一个页签。
*/
function mergeable({ schemaVersion: _v, area, order, query }) {
return { area: area || query?.default?.area, order: order || query?.default?.order }
}
/**
* 上一层覆盖下一层:把 pref 的 { area, order } 叠加到 base 之上。
* @param allowedFields 允许出现的字段集合(已按字段权限与字段定义裁剪)
*/
export function mergeQueryLayout(base, pref, allAllowed) {
const area = { ...base.area }
const order = { ...base.order }
const merged = mergeable(pref || {})
const allowed = new Set(allAllowed)
if (merged.area) {
for (const [field, value] of Object.entries(merged.area)) {
if (!allowed.has(field)) continue
if (value === 'quick' || value === 'advanced' || value === 'hidden') area[field] = value
}
}
if (merged.order) {
for (const [field, value] of Object.entries(merged.order)) {
if (!allowed.has(field)) continue
order[field] = Number(value) || 0
}
}
return { area, order }
}
/**
* 计算「当前布局」相对某基线的增量覆盖:只保留与基线不同的字段。
* 增量覆盖即三层各自应存的内容:个人/企业层只存相对上一层改动的字段,默认 Schema 才能自然继承。
*/
export function layoutDelta(current, base) {
const area = {}
const order = {}
for (const [field, value] of Object.entries(current.area || {})) {
if (base.area?.[field] !== value) area[field] = value
}
for (const [field, value] of Object.entries(current.order || {})) {
if (base.order?.[field] !== value) order[field] = value
}
return { schemaVersion: 1, area, order }
}
/** 增量是否为空(无实际覆盖则无需落库) */
export function isEmptyDelta(delta) {
return (
!delta ||
(Object.keys(delta.area || {}).length === 0 && Object.keys(delta.order || {}).length === 0)
)
export function mergeDefaultViewCode(baseCode, pref, allowedCodes) {
const base = String(baseCode ?? '')
const saved = String(pref?.defaultViewCode ?? '')
if (!saved) return base
return new Set((allowedCodes || []).map(String)).has(saved) ? saved : base
}
/* ================= 栏位布局(view)个人层 =================
@@ -102,13 +102,11 @@ function toggleCollapse() {
</template>
<style scoped lang="scss">
/* 组:不描边——宿主本身就是白卡 / 弹窗,再画一圈会和宿主形成「框套框」;
分层交给卡头淡底 + 一层极轻投影。与表单设计器的分组卡片(.mm-fdesign__section)同族 */
/* 组:不描边、不填底、不加投影——宿主本身就是白卡 / 弹窗,再画一圈会和宿主形成「框套框」;
分层仅靠卡头淡底。与表单设计器的分组卡片(.mm-fdesign__section)同族 */
.fms-form-group {
min-width: 0;
border-radius: 8px;
background: var(--fms-card);
box-shadow: 0 1px 2px rgba(39, 54, 70, 0.05);
}
/* ========== 卡头:通栏淡底(无分隔线,淡底与白底的分界即边界) ========== */
@@ -23,6 +23,8 @@ const props = defineProps({
maxlength: { type: [String, Number], default: undefined },
/** 多行时的初始行数 */
rows: { type: [String, Number], default: 3 },
/** 占位文案:必须声明为 prop 才能落到内部 input,否则会当作 fallthrough 属性挂到外层容器上 */
placeholder: { type: String, default: undefined },
/** 浏览器自动填充语义:username / current-password / off 等 */
autocomplete: { type: String, default: undefined },
})
@@ -309,6 +311,7 @@ onBeforeUnmount(() => {
:disabled="disabled"
:readonly="readonly"
:maxlength="maxlength"
:placeholder="placeholder"
:autocomplete="autocomplete"
:aria-invalid="status === 'error' || undefined"
@input="onInput"
@@ -327,6 +330,7 @@ onBeforeUnmount(() => {
:readonly="readonly"
:rows="rows"
:maxlength="maxlength"
:placeholder="placeholder"
:autocomplete="autocomplete"
:style="textareaStyle"
:aria-invalid="status === 'error' || undefined"