20260923165454
This commit is contained in:
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"
|
||||
|
||||
Reference in new issue
Block a user