20260923230500

This commit is contained in:
oneao committed 2026-09-23 23:05:01 +08:00
1 parent e6935b8fa4
commit 0405cd198a
36 files changed
+3668 -911

No files matched your search

@@ -28,6 +28,21 @@ export function queryItemLabel(item, translate) {
return typeof translate === 'function' ? translate(field.b_i18n, fallback) : fallback
}
/**
* 关键词输入框占位文案:按可搜索字段名在运行时生成,不写进模块配置。
* 字段多于 5 个时只列前 5 个,末尾加「等」。
* @param items 关键词字段项(queryItem[])
* @param translate 多语言函数(t(key, fallback)),缺省用中文默认文案
*/
export function buildKeywordPlaceholder(items, translate) {
const labels = (items || []).map((item) => queryItemLabel(item, translate)).filter(Boolean)
if (!labels.length) return ''
const text = (key, fallback) => (typeof translate === 'function' ? translate(key, fallback) : fallback)
const head = labels.slice(0, 5).join('、')
const tail = labels.length > 5 ? ` ${text('common.etc', '等')}` : ''
return `${text('common.keywordSearch', '搜索')} ${head}${tail}`
}
export function isRangeQuery(item) {
return (
item.config.b_operator === 'between' ||
@@ -111,7 +126,8 @@ export function sqlLiteral(value, field, endOfRange = false) {
/* ---------- 统一查询条件:AST → SQL ----------
* 页签条件、关键词、快捷筛选、更多筛选都归一到同一套结构,最终由 astToSql 生成 SQL:
* 条件 { type: 'condition', field, operator, value, join }
* 条件组 { type: 'group', children: [条件 | 条件组], logic? }
* 条件组 { type: 'group', children: [条件 | 条件组 | 原始片段], logic? }
* 原始片段 { type: 'raw', sql }(页签条件由配置人员直接写 SQL,整段作为一个子项参与连接)
* join 表示「本项与上一相邻项如何连接」(且 / 或);首项 join 忽略。
* 页签 / 快捷筛选 / 更多筛选同层一律「且」,只有关键词在多个字段之间取「或」。
*/
@@ -336,6 +352,23 @@ export function buildConditionSql(node, fieldMap) {
return `${column} ${operator === 'ne' ? '<>' : '='} ${literal}`
}
/* 原始 SQL 片段 → SQL:整段加括号,保证它作为子项参与连接时优先级独立。
* 不做任何改写(分号 / 注释由配置面板拦截):运行时静默丢弃条件会把它放宽成「更多数据」,
* 比原样执行更危险。 */
function buildRawSql(node) {
const sql = String(node?.sql ?? '').trim()
return sql ? `(${sql})` : ''
}
/** 页签条件 SQL 的基础校验:只防手误(分号、注释),不当安全边界 */
export function validateConditionSql(sql) {
const text = String(sql ?? '').trim()
if (!text) return true
if (text.includes(';')) return '不能包含分号'
if (text.includes('--') || text.includes('/*')) return '不能包含注释'
return true
}
/** 条件组 → SQL(递归)。子项自带 join 时按各自连接符拼接:
* 全部同向 → 简单连接;混合 → 从左到右逐项加括号,与界面阅读顺序一致
* (a 且 b 或 c = (a 且 b) 或 c)。
@@ -345,7 +378,11 @@ export function buildGroupSql(group, fieldMap) {
const parts = []
for (const child of group.children || []) {
const sql =
child?.type === 'group' ? buildGroupSql(child, fieldMap) : buildConditionSql(child, fieldMap)
child?.type === 'group'
? buildGroupSql(child, fieldMap)
: child?.type === 'raw'
? buildRawSql(child)
: buildConditionSql(child, fieldMap)
if (sql) parts.push({ sql, join: child.join })
}
if (!parts.length) return ''
@@ -373,6 +410,7 @@ export function buildGroupSql(group, fieldMap) {
export function astToSql(ast, fieldMap) {
if (!ast) return ''
if (ast.type === 'group') return buildGroupSql(ast, fieldMap)
if (ast.type === 'raw') return buildRawSql(ast)
return buildConditionSql(ast, fieldMap)
}
@@ -395,13 +433,14 @@ export function buildQuickAst(items, values) {
}
/**
* 工作队列页签 → AST。
* 页签条件是配置里写好的完整条件(含日期预设),直接沿用 AST 形状交给 astToSql,
* 因此页签不需要第二套 SQL 生成逻辑。
* @param view query JSON 中的页签节点({ conditions: [...] })
* @param fieldMap 「字段编码 → s_field 行」的 Map;字段缺失的条件直接丢弃
* 工作队列页签 → AST:条件为配置直写的 SQL 片段(conditionSql),整段作为 raw 子项参与连接。
* 存量页签的结构化 conditions 继续兼容(含日期语义值),因此不需要第二套 SQL 生成逻辑。
* @param view query JSON 中的页签节点({ conditionSql } 或 { conditions: [...] })
* @param fieldMap 「字段编码 → s_field 行」的 Map;旧结构里字段缺失的条件直接丢弃
*/
export function buildViewAst(view, fieldMap) {
const sql = String(view?.conditionSql ?? '').trim()
if (sql) return { type: 'raw', sql }
const children = []
for (const condition of view?.conditions || []) {
if (!condition || condition.field == null) continue
@@ -131,14 +131,14 @@ export function buildEditRender(editSchema) {
}
/**
* query JSON(schemaVersion 2)→ 查询入口渲染结构。
* query JSON(schemaVersion 3)→ 查询入口渲染结构。
*
* 产出(运行时组件只消费这一份,不再感知 JSON 节点差异):
* - views 工作队列页签(按 order 排序)
* - defaultViewCode 模块配置的默认页签编码
* - keywordSearch 关键词搜索(占位文案 / 触发方式 / 字段项),未配置时为 null
* - views 工作队列页签(按 order 排序,带分组;组内单选、组间条件叠加)
* - defaultViewCodes 分组 → 默认页签编码(每组至多一个)
* - keywordSearch 关键词搜索(字段项;占位文案由调用方按字段名运行时生成),未配置时为 null
* - quickFilters 快捷筛选字段项(按 order 排序)
* - filterGroups 更多筛选分组(组内字段项按配置顺序)
* - moreFilters 更多筛选字段项(字段查询元数据全集,已进快捷筛选的不重复出现)
*
* 字段项(queryItem)统一为 { field, config, options, optionConfig },与 FmsQueryControl /
* buildQuickAst 消费的形状一致;字段级算子只在 queryFields 里定义一处。
@@ -177,31 +177,43 @@ export function buildQueryRender(querySchema, fields, allowedFields = null) {
const views = []
for (const [index, view] of (querySchema?.views || []).entries()) {
if (!view || view.code == null) continue
const conditionSql = String(view.conditionSql ?? '').trim()
const conditions = (view.conditions || []).filter(
(condition) => condition && condition.field != null,
)
// 条件字段不可用(未定义 / 无查询权限)时丢弃整个页签:静默丢单个条件会把
// 「待处理」悄悄放宽成更多数据,与页签语义不符。
if (conditions.some((condition) => !itemOf(condition.field))) continue
// 旧结构(结构化条件)里条件字段不可用(未定义 / 无查询权限)时丢弃整个页签:静默丢单个
// 条件会把「待处理」悄悄放宽成更多数据,与页签语义不符。
// SQL 条件无法解析字段引用,原样保留(由配置人员核对)。
if (!conditionSql && conditions.some((condition) => !itemOf(condition.field))) continue
views.push({
code: String(view.code),
label: String(view.label ?? view.code),
group: String(view.group ?? ''),
order: Number(view.order) || (index + 1) * 10,
showCount: view.showCount === true,
isDefault: view.isDefault === true,
conditionSql,
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
// 默认页签按组给:显式 isDefault 优先(每组取第一个);老数据只有顶层 defaultViewCode,
// 按其所在组补一个默认,保证老配置切过去仍落在原页签上
const defaultViewCodes = {}
for (const view of views) {
if (!view.isDefault || defaultViewCodes[view.group] != null) continue
defaultViewCodes[view.group] = view.code
}
const legacyDefaultCode = String(querySchema?.defaultViewCode ?? '')
if (legacyDefaultCode && !views.some((view) => view.isDefault)) {
const target = views.find((view) => view.code === legacyDefaultCode)
if (target) defaultViewCodes[target.group] = target.code
}
// 关键词:字段清单来自配置,占位文案与触发方式都在运行时定(不入配置)
const keywordItems = (querySchema?.keywordSearch?.fields || []).map(itemOf).filter(Boolean)
const keywordSearch = keywordItems.length ? { items: keywordItems } : null
const quickFilters = (querySchema?.quickFilters || [])
.map((entry, index) => {
@@ -212,27 +224,14 @@ export function buildQueryRender(querySchema, fields, allowedFields = null) {
.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)
const moreFilters = Object.keys(queryFields)
.map(itemOf)
.filter((item) => item && !quickFieldNames.has(String(item.field.b_field)))
return {
views,
defaultViewCode: String(querySchema?.defaultViewCode ?? views[0]?.code ?? ''),
keywordSearch,
quickFilters,
filterGroups,
}
return { views, defaultViewCodes, keywordSearch, quickFilters, moreFilters }
}
/** 节点(含分组子树)在 settings 顺序中的最小序号,用于同层稳定排序 */
@@ -1,8 +1,8 @@
<script setup>
/**
* 更多筛选(抽屉):按模块配置的字段分组铺开完整筛选字段,控件按字段类型渲染。
* 字段清单、分组、顺序、算子全部来自查询配置 —— 页面与本组件都不内置模块专用选项,
* 也不再提供自由拼装条件行/条件组的入口(技术表达式不由业务人员拼)。
* 更多筛选(抽屉):铺开未被快捷筛选占用的其余查询字段,控件按字段类型渲染。
* 字段清单、顺序、算子全部来自查询配置,按配置顺序平铺(不再人工分组)——
* 页面与本组件都不内置模块专用选项,也不提供自由拼装条件行/条件组的入口。
*
* 抽屉内编辑的只是草稿:「应用筛选」才生效并回到第一页,「取消」丢弃改动(由页面重开时重置草稿),
* 「重置」只清空抽屉里的草稿值,同样需要「应用筛选」才落到列表。
@@ -23,8 +23,8 @@ import { useI18nStore } from '@/stores/i18n'
const props = defineProps({
open: { type: Boolean, default: false },
// 更多筛选分组:[{ code, label, order, items: queryItem[] }]
groups: { type: Array, default: () => [] },
// 更多筛选字段项(扁平,按配置顺序;已进快捷筛选的字段不会出现)
items: { type: Array, default: () => [] },
// 抽屉草稿:{ [b_field]: { operator, value } }
modelValue: { type: Object, default: () => ({}) },
})
@@ -104,14 +104,10 @@ function labelOf(item) {
}
/** 草稿里已填写的条件数(页脚提示用) */
const draftCount = computed(() =>
flattenItems(props.groups).filter((item) => hasConditionValue(entryOf(item).value)).length,
const draftCount = computed(
() => (props.items || []).filter((item) => hasConditionValue(entryOf(item).value)).length,
)
function flattenItems(groups) {
return (groups || []).flatMap((group) => group.items || [])
}
function reset() {
emit('update:modelValue', {})
}
@@ -131,41 +127,36 @@ function close() {
@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 v-if="items.length" class="filter-list">
<div
v-for="item in 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>
<div v-else class="filter-groups-empty">
<div v-else class="filter-list-empty">
{{ t('common.noMoreFilters', '本模块暂未配置更多筛选字段') }}
</div>
@@ -187,20 +178,7 @@ function close() {
</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 {
.filter-list {
display: flex;
flex-direction: column;
gap: 8px;
@@ -254,7 +232,7 @@ function close() {
box-sizing: border-box;
}
.filter-groups-empty {
.filter-list-empty {
padding: 24px 0;
color: var(--fms-text-secondary);
font-size: var(--fms-font-size);
@@ -1,15 +1,12 @@
<script setup>
/**
* 关键词搜索:对模块配置的可搜索字段做跨字段模糊匹配(字段之间取「或」)。
* 不要求用户先选字段,字段清单与占位文案全部来自模块查询配置。
* 不要求用户先选字段;字段清单来自模块查询配置,占位文案由字段名在运行时生成(不入配置)。
*
* 触发方式由配置给出:
* - enter_or_debounce:回车立即查询,停止输入 400ms 后自动查询(默认)
* - enter:只认回车
* - debounce:只认防抖
* 触发固定为「回车立即查询 + 停止输入 400ms 后自动查询」;
* 清空输入即立即查询(不必再按回车),避免留下一个「看不出为什么还是旧结果」的空条件。
*/
import { computed, onBeforeUnmount, watch } from 'vue'
import { onBeforeUnmount, watch } from 'vue'
import { Search } from '@lucide/vue'
import { Input } from '@/components/ui'
@@ -18,15 +15,11 @@ 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)
@@ -35,13 +28,11 @@ function clearTimer() {
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)
}
@@ -11,6 +11,7 @@ import {
astToSql,
buildFilterStateAst,
buildKeywordAst,
buildKeywordPlaceholder,
buildQuickAst,
buildViewAst,
combineAst,
@@ -43,7 +44,7 @@ import {
loadFieldPower,
loadModulePref,
mergeColumnPref,
mergeDefaultViewCode,
mergeDefaultViewCodes,
removeModulePref,
sameColumnSettings,
saveModulePref,
@@ -103,18 +104,18 @@ const dataSource = shallowRef([])
const grandTotals = shallowRef({})
// ── 查询入口状态 ──
// 查询入口配置(s_module_schema.query,schemaVersion 2):页签 / 关键词 / 快捷筛选 / 更多筛选分组
// 查询入口配置(s_module_schema.query,schemaVersion 3):页签(可分组)/ 关键词 / 快捷筛选 / 更多筛选
const queryRender = shallowRef({
views: [],
defaultViewCode: '',
defaultViewCodes: {},
keywordSearch: null,
quickFilters: [],
filterGroups: [],
moreFilters: [],
})
// 当前页签(工作队列视图)
const viewCode = ref('')
// 模块配置的默认页签(系统默认层)
const defaultViewCode = ref('')
// 当前选中的页签(分组 → 编码;每组可单独取消,组间条件叠加)
const viewCodes = ref({})
// 模块配置的默认页签(系统默认层,分组 → 编码)
const defaultViewCodes = ref({})
const keyword = ref('')
// 快捷筛选:草稿(界面绑定)与生效值分开,点「查询」才落到生效
const quickValues = ref({})
@@ -131,7 +132,7 @@ let countsToken = 0
const queryAst = shallowRef(null)
// 个人层默认页签偏好(s_user_module_pref, b_schema_type='query')
const prefRowExists = ref(false)
const savedDefaultViewCode = ref('')
const savedDefaultViewCodes = ref({})
// ── 栏位设置(列宽/顺序)──
// 基线:进入本页(或保存/重载)时的栏位快照,用于判断「列是否已被调整」。
@@ -256,7 +257,7 @@ const moreMenuItems = computed(() => [
key: 'set-default-view',
label: t('common.setDefaultView', '将当前页签设为我的默认'),
icon: h(Star, { size: 14 }),
disabled: !dataModule.value || !viewCode.value,
disabled: !dataModule.value || !Object.keys(viewCodes.value).length,
},
{
key: 'custom-export',
@@ -275,17 +276,19 @@ const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.st
// ── 查询入口派生 ──
const views = computed(() => queryRender.value.views)
const keywordConfig = computed(() => queryRender.value.keywordSearch)
/** 关键词输入框占位文案:按可搜索字段名运行时生成(配置里不保存文案) */
const keywordPlaceholder = computed(() =>
buildKeywordPlaceholder(keywordConfig.value?.items || [], t),
)
const quickItems = computed(() => queryRender.value.quickFilters)
const filterGroups = computed(() => queryRender.value.filterGroups)
const moreFilters = computed(() => queryRender.value.moreFilters)
// 字段编码 → 查询条件项:条件回显文案与更多筛选都从这里取字段元数据
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)
}
for (const item of moreFilters.value) push(item)
return map
})
@@ -480,15 +483,18 @@ async function loadModuleConfig() {
columnDirty.value = false
// 查询入口:默认页签(系统默认 ← 个人覆盖)与生效条件。切模块即回到配置的默认页签,
// 个人层只允许挑一个已存在且有权限的页签,不能凭空造页签。
// 个人层只能在该组已存在且有权限的页签里挑,不能凭空造页签。
queryRender.value = render
defaultViewCode.value = render.defaultViewCode
savedDefaultViewCode.value = String(savedPref?.defaultViewCode || '')
defaultViewCodes.value = { ...render.defaultViewCodes }
savedDefaultViewCodes.value = {
...(savedPref?.defaultViewCodes ||
(savedPref?.defaultViewCode ? { '': String(savedPref.defaultViewCode) } : {})),
}
prefRowExists.value = Boolean(savedPref)
viewCode.value = mergeDefaultViewCode(
render.defaultViewCode,
viewCodes.value = mergeDefaultViewCodes(
render.defaultViewCodes,
savedPref,
render.views.map((view) => view.code),
allowedViewCodesOf(render.views),
)
quickValues.value = defaultQuickValues(render.quickFilters)
appliedQuick.value = { ...quickValues.value }
@@ -542,15 +548,36 @@ function withTieBreaker(orderSql, field) {
return sorted ? orderSql : `${orderSql}, ${field} ASC`
}
/** 分组 → 该组可用页签编码集合(个人默认页签只能落在允许的集合里)。
* 无原型对象:分组名由管理员填写,避免 '__proto__' 之类的键命中原型链 */
function allowedViewCodesOf(viewList) {
const map = Object.create(null)
for (const view of viewList || []) {
const group = String(view.group ?? '')
if (!map[group]) map[group] = new Set()
map[group].add(String(view.code))
}
return map
}
/** 页签选中集合(分组 → 编码)→ 各页签 AST:组间条件叠加,某组不限即不加该组条件 */
function viewAstsOf(codes) {
const asts = []
for (const code of Object.values(codes || {})) {
const view = views.value.find((item) => item.code === code) || null
if (view) asts.push(buildViewAst(view, activeFieldById.value))
}
return asts
}
/**
* 指定页签下的完整查询 AST:固定条件之外的四个来源按「且」合并 ——
* 页签预设条件 / 关键词 / 快捷筛选 / 更多筛选。
* 完整查询 AST:固定条件之外的四个来源按「且」合并 ——
* 选中的页签条件 / 关键词 / 快捷筛选 / 更多筛选。
* 页签条件与用户条件固定 AND,用户永远是在当前工作队列里继续收窄。
*/
function buildQueryAstOf(code) {
const view = views.value.find((item) => item.code === code) || null
function buildQueryAstOf(codes) {
return combineAst(
buildViewAst(view, activeFieldById.value),
...viewAstsOf(codes),
buildKeywordAst(keyword.value, keywordConfig.value?.items || []),
buildQuickAst(quickItems.value, appliedQuick.value),
buildFilterStateAst(appliedFilters.value, queryItemByField.value),
@@ -559,7 +586,7 @@ function buildQueryAstOf(code) {
/** 当前生效的查询 AST(任何条件变化后重新计算,供 SQL 生成与条件回显共用) */
function buildQueryAst() {
return buildQueryAstOf(viewCode.value)
return buildQueryAstOf(viewCodes.value)
}
/**
@@ -580,6 +607,13 @@ function buildSearchCondition() {
return conditionSqlOf(queryAst.value)
}
/** 页签数量的条件:把目标页签当作它所在组的选中,其余组保持当前选中(「切过去会看到多少条」) */
function tabCountConditionSql(code) {
const view = views.value.find((item) => item.code === code) || null
const group = String(view?.group ?? '')
return conditionSqlOf(buildQueryAstOf({ ...viewCodes.value, [group]: code }))
}
async function fetchData() {
if (!dataModule.value || !dataModule.value.b_view_table) return
@@ -664,14 +698,14 @@ async function initialize() {
editConfigById.value = new Map()
queryRender.value = {
views: [],
defaultViewCode: '',
defaultViewCodes: {},
keywordSearch: null,
quickFilters: [],
filterGroups: [],
moreFilters: [],
}
viewCode.value = ''
defaultViewCode.value = ''
savedDefaultViewCode.value = ''
viewCodes.value = {}
defaultViewCodes.value = {}
savedDefaultViewCodes.value = {}
keyword.value = ''
quickValues.value = {}
appliedQuick.value = {}
@@ -738,10 +772,10 @@ function searchKeyword(value) {
return refreshQuery()
}
/** 切换页签:页签条件与用户条件固定「且」,用户已填条件保留 */
function changeView(code) {
if (loading.value || !code || code === viewCode.value) return
viewCode.value = code
/** 切换页签:页签条件与用户条件固定「且」,用户已填条件保留(多组可同时生效) */
function changeView(codes) {
if (loading.value) return
viewCodes.value = { ...(codes || {}) }
return refreshQuery()
}
@@ -802,23 +836,23 @@ function clearAllConditions() {
/* ── 个人层默认页签(s_user_module_pref, b_schema_type='query') ── */
/** 把当前页签设为「我的默认」;当它已是系统默认时,反而清掉个人覆盖(回落到系统默认) */
/** 把当前选中的页签组合设为「我的默认」;与系统默认一致时清掉个人覆盖(回落系统默认) */
async function setDefaultView() {
const userId = currentUserId()
const moduleId = String(dataModule.value?.b_id || '').trim()
if (!userId || !moduleId || loading.value) return
const delta = viewCodeDelta(viewCode.value, defaultViewCode.value)
const delta = viewCodeDelta(viewCodes.value, defaultViewCodes.value)
try {
if (!delta) {
if (prefRowExists.value) {
await removeModulePref(userId, moduleId)
prefRowExists.value = false
}
savedDefaultViewCode.value = ''
savedDefaultViewCodes.value = {}
} else {
await saveModulePref(userId, moduleId, delta, prefRowExists.value)
prefRowExists.value = true
savedDefaultViewCode.value = delta.defaultViewCode
savedDefaultViewCodes.value = { ...delta.defaultViewCodes }
}
Message.success(t('common.defaultViewSaved', '已设为我的默认页签'))
} catch (error) {
@@ -865,7 +899,7 @@ async function loadTabCounts(codes) {
viewName: module.b_view_table,
orderBy,
searchColumns: [],
searchCondition: conditionSqlOf(buildQueryAstOf(code)),
searchCondition: tabCountConditionSql(code),
pageNo: 1,
pageSize: 1,
})
@@ -1528,20 +1562,23 @@ defineExpose({
<template v-else>
<div class="module-main-column">
<!-- 上:查询卡(工作队列页签 + 关键词 + 快捷筛选 + 条件回显),全部由模块查询配置驱动 -->
<section v-if="views.length" class="fms-module-card query-card">
<!-- 上:查询卡(工作队列页签 + 关键词 + 快捷筛选 + 条件回显),全部由模块查询配置驱动;
只要任一入口有配置就渲染(模块可能只配了关键词或快捷筛选) -->
<section
v-if="views.length || keywordConfig || quickItems.length"
class="fms-module-card query-card"
>
<FmsWorkQueueTabs
:views="views"
:active-code="viewCode"
:active-codes="viewCodes"
:counts="tabCounts"
@update:active-code="changeView"
@update:active-codes="changeView"
@load-counts="requestTabCounts"
/>
<FmsKeywordSearch
v-if="keywordConfig"
v-model="keyword"
:placeholder="keywordConfig.placeholder"
:trigger="keywordConfig.trigger"
:placeholder="keywordPlaceholder"
@search="searchKeyword"
/>
<FmsQuickFilters
@@ -1550,6 +1587,7 @@ defineExpose({
:items="quickItems"
:loading="loading"
:advanced-count="appliedFilterCount"
:advanced-enabled="moreFilters.length > 0"
@search="search"
@reset="resetSearch"
@open-advanced="openFilters"
@@ -1686,7 +1724,7 @@ defineExpose({
<FmsFilterDrawer
v-model="filterDraft"
v-model:open="filterOpen"
:groups="filterGroups"
:items="moreFilters"
@apply="applyFilters"
/>
@@ -24,6 +24,8 @@ const props = defineProps({
loading: { type: Boolean, default: false },
// 已生效的更多筛选条件数(角标)
advancedCount: { type: Number, default: 0 },
// 是否显示「更多筛选」入口:模块没有可展开的字段时不显示(避免打开空抽屉)
advancedEnabled: { type: Boolean, default: true },
})
const emit = defineEmits(['update:modelValue', 'search', 'reset', 'open-advanced'])
const { t } = useI18nStore()
@@ -79,7 +81,12 @@ function submit() {
<Button type="outline" :disabled="loading" @click="emit('reset')">
{{ t('common.reset', '重置') }}
</Button>
<Button type="ghost" class="quick-more" @click="emit('open-advanced')">
<Button
v-if="advancedEnabled"
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>
@@ -1,10 +1,13 @@
<script setup>
/**
* 工作队列页签:预置查询视图(全部 / 待处理 / 有异常 …),进入页面即可用。
* 页签编码、文案、顺序、默认项、是否显示数量、预设条件全部来自模块查询配置,
* 页签编码、文案、顺序、分组、默认项、是否显示数量、预设条件(SQL)全部来自模块查询配置,
* 本组件不含任何模块专用分支,也不自己请求数据。
*
* 数量由页面按需懒加载:首屏只取可见页签,超出部分收进「更多」,展开时才补取。
* 分组语义:一个分组是一行页签,组内单选(再点一次已选中的页签即取消,回到该组「不限」),
* 组间条件叠加(如「状态」选待处理 + 「审核状态」选已审核 = 两个条件取且)。
*
* 数量由页面按需懒加载:首屏只取每组可见页签,超出部分收进该组「更多」,展开时才补取。
* 统计失败不阻塞列表 —— 数量为空即不显示徽标,页签仍可切换。
*/
import { computed, ref, watch } from 'vue'
@@ -13,27 +16,66 @@ import { Dropdown } from '@/components/ui'
import { useI18nStore } from '@/stores/i18n'
const props = defineProps({
// 已排序的页签配置:[{ code, label, order, showCount, conditions }]
// 已排序的页签配置:[{ code, label, group, order, showCount, conditionSql, conditions }]
views: { type: Array, default: () => [] },
activeCode: { type: String, default: '' },
// 分组 → 当前选中的页签编码(缺省 / 不匹配表示该组不限)
activeCodes: { type: Object, default: () => ({}) },
// 页签编码 → 数量(缺失或非数字表示「暂无数量」)
counts: { type: Object, default: () => ({}) },
// 直接铺开的页签数量上限,其余进「更多」
// 每组直接铺开的页签数量上限,其余进「更多」
visibleLimit: { type: Number, default: 6 },
})
const emit = defineEmits(['update:activeCode', 'load-counts'])
const emit = defineEmits(['update:activeCodes', 'load-counts'])
const { t } = useI18nStore()
const overflowOpen = ref(false)
/** 按分组切分页签:组的顺序即配置顺序(同组页签已按 order 排好) */
const viewGroups = computed(() => {
const groups = []
const byName = new Map()
for (const view of props.views || []) {
const name = String(view.group ?? '')
if (!byName.has(name)) {
const entry = { name, tabs: [], visible: [], overflow: [] }
byName.set(name, entry)
groups.push(entry)
}
byName.get(name).tabs.push(view)
}
for (const entry of groups) {
entry.visible = entry.tabs.slice(0, props.visibleLimit)
entry.overflow = entry.tabs.slice(props.visibleLimit)
}
return groups
})
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),
)
/** 只有一个默认组时不显示组标签,视觉上与单行页签一致 */
const hasNamedGroup = computed(() => (props.views || []).some((view) => String(view.group ?? '')))
function isActive(view) {
return String(props.activeCodes?.[String(view.group ?? '')] ?? '') === String(view.code)
}
/** 组内单选:点未选中的页签即选中;点已选中的页签即取消(该组回到「不限」) */
function select(view) {
const group = String(view.group ?? '')
const next = { ...(props.activeCodes || {}) }
if (next[group] === view.code) delete next[group]
else next[group] = view.code
emit('update:activeCodes', next)
}
function overflowItems(entry) {
return entry.overflow.map((view) => ({ key: view.code, label: view.label }))
}
function onOverflowClick(entry, { key }) {
const view = entry.overflow.find((item) => item.code === key)
if (view) select(view)
}
function isOverflowActive(entry) {
return entry.overflow.some((view) => isActive(view))
}
/** 页签数量:配置未开启统计或还没拿到数据时为 null(不渲染徽标) */
function countOf(view) {
@@ -43,72 +85,87 @@ function countOf(view) {
}
/** 只请求需要数量的页签,过滤失败/未开启的 */
function countsOf(views) {
return views.filter((view) => view.showCount).map((view) => view.code)
function requestCounts(views) {
const codes = (views || []).filter((view) => view.showCount).map((view) => view.code)
if (codes.length) emit('load-counts', codes)
}
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)
},
() => viewGroups.value.flatMap((entry) => entry.visible),
(views) => requestCounts(views),
{ immediate: true },
)
// 「更多」展开时才补取其余页签的数量:不展开就不为看不见的页签发请求
watch(overflowOpen, (open) => {
if (!open) return
const codes = countsOf(overflowViews.value)
if (codes.length) emit('load-counts', codes)
})
// 某组「更多」展开时才补取该组其余页签的数量:不展开就不为看不见的页签发请求
function onOverflowOpen(entry, open) {
if (open) requestCounts(entry.overflow)
}
</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>
<div v-for="entry in viewGroups" :key="entry.name" class="work-queue-group">
<span v-if="hasNamedGroup" class="work-queue-group__label">{{ entry.name }}</span>
<div class="work-queue-group__tabs">
<button
v-for="view in entry.visible"
:key="view.code"
type="button"
class="work-queue-tab"
:class="{ 'is-active': isActive(view) }"
@click="select(view)"
>
{{ 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>
<Dropdown
v-if="entry.overflow.length"
:items="overflowItems(entry)"
placement="bottom-start"
@click="onOverflowClick(entry, $event)"
@update:open="onOverflowOpen(entry, $event)"
>
<button
type="button"
class="work-queue-tab work-queue-tab--more"
:class="{ 'is-active': isOverflowActive(entry) }"
>
{{ t('common.more', '更多') }}
<ChevronDown :size="14" />
</button>
</Dropdown>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.fms-work-queue-tabs {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.work-queue-group {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
}
/* 组标签:只有存在具名分组时渲染,默认组渲染空标签占位,保证各行页签左边缘对齐 */
.work-queue-group__label {
flex: none;
min-width: 32px;
color: var(--fms-text-tertiary);
font-size: 12px;
white-space: nowrap;
}
.work-queue-group__tabs {
display: flex;
min-width: 0;
align-items: center;
@@ -1,6 +1,6 @@
// 布局读写与两层继承合并(系统默认 → 个人)。
// s_module_schema —— 系统默认。查询入口以 query JSON(页签 + 关键词 + 快捷筛选 +
// 更多筛选分组 + 字段算子)为来源;列宽顺序以 view JSON(columns 节点)
// s_module_schema —— 系统默认。查询入口以 query JSON(页签分组 + 关键词 + 快捷筛选 +
// 字段查询元数据)为来源;列宽顺序以 view JSON(columns 节点)
// 为来源。query 布局由模块管理页维护,「保存为通用」不算布局调整。
// s_user_module_pref —— 个人覆盖(b_schema_type + b_schema_json),只存相对系统默认的增量。
// 两类偏好(query 默认页签 / view 栏位布局)共用本文件的读写与增量计算,靠 schemaType 区分。
@@ -23,25 +23,49 @@ export function currentUserId() {
}
/**
* 个人默认页签 → 增量覆盖({ schemaVersion: 1, defaultViewCode })。
* 与系统默认一致时不落库(返回 null):两层继承下「无覆盖」就是跟随系统默认。
* 个人默认页签 → 增量覆盖({ schemaVersion: 2, defaultViewCodes: { [分组]: 页签编码 } })。
* 只记与系统默认不同的分组;完全没有差异时不落库(返回 null):
* 两层继承下「无覆盖」就是跟随系统默认。
* @param currentCodes 当前选中的页签(分组 → 编码)
* @param baseCodes 系统默认页签(分组 → 编码)
*/
export function viewCodeDelta(currentCode, baseCode) {
const current = String(currentCode ?? '')
if (!current || current === String(baseCode ?? '')) return null
return { schemaVersion: 1, defaultViewCode: current }
export function viewCodeDelta(currentCodes, baseCodes) {
const delta = {}
const groups = new Set([
...Object.keys(baseCodes || {}),
...Object.keys(currentCodes || {}),
])
for (const group of groups) {
const current = String(currentCodes?.[group] ?? '')
if (!current || current === String(baseCodes?.[group] ?? '')) continue
delta[group] = current
}
return Object.keys(delta).length ? { schemaVersion: 2, defaultViewCodes: delta } : null
}
/**
* 个人层默认页签叠加到系统默认之上。
* 个人层默认页签叠加到系统默认之上,返回「分组 → 页签编码」。
* 个人值必须是本模块当前真实可用的页签编码(页签被删除 / 无权限时自动回落系统默认),
* 偏好只能挑页签,不能凭空造出一个页签。
* 兼容 v1 个人层(只有一个 defaultViewCode):视作默认组('')的覆盖。
* @param allowedCodesByGroup 分组 → 该组可用页签编码集合
*/
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
export function mergeDefaultViewCodes(baseCodes, pref, allowedCodesByGroup) {
const merged = { ...(baseCodes || {}) }
const saved = pref?.defaultViewCodes && typeof pref.defaultViewCodes === 'object'
? pref.defaultViewCodes
: pref?.defaultViewCode
? { '': String(pref.defaultViewCode) }
: null
if (!saved) return merged
for (const [group, code] of Object.entries(saved)) {
const allowed =
allowedCodesByGroup instanceof Map
? allowedCodesByGroup.get(group)
: allowedCodesByGroup?.[group]
if (allowed?.has?.(String(code))) merged[group] = String(code)
}
return merged
}
/* ================= 栏位布局(view)个人层 =================