-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
emit('more-action', event)"
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -165,76 +128,87 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true })
diff --git a/code/fms/fms-vue/src/components/fms-module-list/useModulePref.js b/code/fms/fms-vue/src/components/fms-module-list/useModulePref.js
new file mode 100644
index 00000000..f16a3fc4
--- /dev/null
+++ b/code/fms/fms-vue/src/components/fms-module-list/useModulePref.js
@@ -0,0 +1,173 @@
+// 查询布局读写与两层继承合并(系统默认 → 个人)。
+// s_module_schema —— 系统默认。查询布局以 query JSON(quick 列表 + 条件)为来源;
+// 由 schemaRender.applyQueryLayout 在「保存为通用」时回写。
+// s_user_module_pref —— 个人覆盖(b_schema_type + b_schema_json),只存相对系统默认的增量。
+// 布局只能调整区域与顺序,不得放宽字段权限——合并后仍按字段权限裁剪(开发规范 通用原则 10)。
+import { loadDataApi, saveObjectApi } from '@/services/api'
+import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
+import { useAuthStore } from '@/stores/auth'
+
+const SCHEMA_TYPE = 'query'
+const USER_PREF_TABLE = 's_user_module_pref'
+const USER_PREF_KEY = 'b_user_id,b_module_id,b_schema_type'
+
+/** 当前用户编码(b_user.b_id):登录账号优先,回退会话用户信息 */
+export function currentUserId() {
+ const auth = useAuthStore()
+ return String(auth.userInfo?.account || auth.loginInfo?.user?.b_id || '').trim()
+}
+
+/**
+ * 模块默认查询布局。启用与否只由「启用」开关决定(area 非 hidden 即启用):
+ * 1. 查询 JSON 节点上显式配 hidden 的字段默认不启用;其余(含历史数据里的
+ * quick / advanced)都按启用处理,简单查询按顺序取前几条、其余由「展开」收起;
+ * 2. 模块配置的 quick 列表只用来定顺序:列表内的字段排在前面,其余按查询条件顺序。
+ */
+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 }
+}
+
+/**
+ * 单层覆盖 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)
+}
+
+/** 读取个人层(s_user_module_pref)某一 schema 类型的覆盖 JSON;无记录或解析失败返回 null */
+export async function loadModulePref(userId, moduleId) {
+ if (!userId || !moduleId) return null
+ const condition =
+ `b_user_id = N'${escapeSqlText(userId)}' AND b_module_id = N'${escapeSqlText(moduleId)}' ` +
+ `AND b_schema_type = N'${SCHEMA_TYPE}'`
+ const res = await loadDataApi(USER_PREF_TABLE, condition, 'b_schema_type', [
+ 'b_user_id',
+ 'b_module_id',
+ 'b_schema_type',
+ 'b_schema_json',
+ ])
+ return parseRow(res.data?.[0])
+}
+
+function parseRow(row) {
+ if (!row?.b_schema_json) return null
+ try {
+ return JSON.parse(row.b_schema_json)
+ } catch {
+ return null
+ }
+}
+
+/**
+ * 写个人层覆盖(s_user_module_pref)。后端 saveobject 无 upsert:insert 冲突重试 updates。
+ */
+export async function saveModulePref(userId, moduleId, pref, rowExists) {
+ if (!userId || !moduleId) return false
+ const row = {
+ b_user_id: userId,
+ b_module_id: moduleId,
+ b_schema_type: SCHEMA_TYPE,
+ b_schema_json: JSON.stringify(pref),
+ b_updated_by: userId,
+ b_updated_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
+ }
+ const request = { table: USER_PREF_TABLE, key_field: USER_PREF_KEY }
+ if (rowExists) request.updates = [row]
+ else request.inserts = [row]
+ try {
+ await saveObjectApi([request])
+ return true
+ } catch (error) {
+ if (rowExists) throw error
+ await saveObjectApi([{ table: USER_PREF_TABLE, key_field: USER_PREF_KEY, updates: [row] }])
+ return true
+ }
+}
+
+/**
+ * 删除某层覆盖(「恢复默认」即删除个人层,回落到系统/企业层或系统默认)。
+ * schemaType 为空数组则删除该模块全部类型(供「恢复出厂」等管理操作使用)。
+ */
+export async function removeModulePref(userId, moduleId, schemaType = SCHEMA_TYPE) {
+ if (!userId || !moduleId) return false
+ const keys = Array.isArray(schemaType) ? schemaType : [schemaType]
+ const deletes = keys.map((type) => ({
+ b_user_id: userId,
+ b_module_id: moduleId,
+ b_schema_type: type,
+ }))
+ await saveObjectApi([{ table: USER_PREF_TABLE, key_field: USER_PREF_KEY, deletes }])
+ return true
+}
+
+/**
+ * 读取字段查询权限(s_user_field_power.b_query)。
+ * 无记录视为可查询;返回「字段编码 → 0/1」,由调用方在偏好合并之后裁剪。
+ */
+export async function loadFieldPower(userId, moduleId) {
+ const power = new Map()
+ if (!userId || !moduleId) return power
+ const condition = `b_user_id = N'${escapeSqlText(userId)}' AND b_module_id = N'${escapeSqlText(moduleId)}' AND b_canuse = 1`
+ const res = await loadDataApi('s_user_field_power', condition, 'b_field_id ASC', [
+ 'b_field_id',
+ 'b_query',
+ ])
+ for (const row of res.data || []) {
+ power.set(String(row.b_field_id), Number(row.b_query) === 0 ? 0 : 1)
+ }
+ return power
+}
\ No newline at end of file
diff --git a/code/fms/fms-vue/src/components/fms-select/FmsSelect.vue b/code/fms/fms-vue/src/components/fms-select/FmsSelect.vue
new file mode 100644
index 00000000..8d41b1cc
--- /dev/null
+++ b/code/fms/fms-vue/src/components/fms-select/FmsSelect.vue
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
diff --git a/code/fms/fms-vue/src/components/fms-select/useLookupOptions.js b/code/fms/fms-vue/src/components/fms-select/useLookupOptions.js
new file mode 100644
index 00000000..b48f7157
--- /dev/null
+++ b/code/fms/fms-vue/src/components/fms-select/useLookupOptions.js
@@ -0,0 +1,154 @@
+// 远程记录查找(lookup)分页数据源:供 FmsSelect 这类"输入即远程搜索 + 滚动加载"的选择器复用。
+// 数据来自通用分页接口 pageDataApi,按 labelKey 做 LIKE 过滤,valueKey 作为取值字段。
+// 只负责取数与分页/防抖/竞态控制,不涉及任何渲染。
+import { ref, watch } from 'vue'
+import { pageDataApi } from '@/services/api'
+
+function escapeSearchText(value) {
+ return String(value).replaceAll("'", "''")
+}
+
+/**
+ * @param {object} config
+ * @param {string} config.viewName 后端表名/视图名(必填)
+ * @param {string} [config.labelKey] 显示文本字段名,默认 'b_name'
+ * @param {string} [config.valueKey] 取值字段名,默认 'b_id'
+ * @param {string} [config.searchCondition] 额外固定查询条件(不含 WHERE)
+ * @param {string} [config.orderBy] 排序,缺省按 labelKey ASC
+ * @param {string[]} [config.searchColumns] 需要返回的字段列表,空则返回全部
+ * @param {number} [config.pageSize] 每页数量,默认 20
+ * @param {number} [config.searchDelay] 搜索防抖(ms),默认 300
+ */
+export function useLookupOptions(config) {
+ const {
+ viewName,
+ labelKey = 'b_name',
+ valueKey = 'b_id',
+ searchCondition = '',
+ orderBy = '',
+ searchColumns = [],
+ pageSize = 20,
+ searchDelay = 300,
+ } = config
+
+ const rows = ref([])
+ const loading = ref(false)
+ const total = ref(0)
+ const keyword = ref('')
+ // 无关键词的首页结果单独缓存:重新展开时先回填,避免"打开即空白再闪一次"
+ const defaultRows = ref([])
+ const defaultTotal = ref(0)
+
+ // 请求版本号:并发请求只认最后一次,旧响应回来直接丢弃,避免快速输入下的竞态错乱
+ let requestVersion = 0
+ let currentPage = 1
+ let debounceTimer = 0
+
+ function buildSearchCondition() {
+ const text = keyword.value.trim()
+ if (!text) return searchCondition
+ const like = `${labelKey} LIKE '%${escapeSearchText(text)}%'`
+ return searchCondition ? `${searchCondition} AND ${like}` : like
+ }
+
+ /**
+ * 加载数据。
+ * @param {boolean} reset true=重置到首页(否则加载下一页并追加)
+ * @param {boolean} preserveRows true=重置分页但保留当前 rows(用于先回填缓存再静默刷新)
+ */
+ async function load(reset = false, preserveRows = false) {
+ if (!viewName) return
+ if (reset) {
+ currentPage = 1
+ if (!preserveRows) {
+ rows.value = []
+ total.value = 0
+ }
+ }
+
+ const version = ++requestVersion
+ const requestedKeyword = keyword.value.trim()
+ loading.value = true
+ try {
+ const result = await pageDataApi({
+ viewName,
+ orderBy: orderBy || `${labelKey} ASC`,
+ searchColumns,
+ searchCondition: buildSearchCondition(),
+ pageNo: currentPage,
+ pageSize,
+ })
+ if (version !== requestVersion) return
+
+ const data = result.data ?? {}
+ const nextRows = Array.isArray(data.rows) ? data.rows : []
+ rows.value = reset ? nextRows : [...rows.value, ...nextRows]
+ total.value = Number(data.total ?? data.row_count) || 0
+
+ if (!requestedKeyword) {
+ defaultRows.value = [...rows.value]
+ defaultTotal.value = total.value
+ }
+ } catch {
+ if (version === requestVersion && reset && !preserveRows) {
+ rows.value = []
+ total.value = 0
+ }
+ } finally {
+ if (version === requestVersion) loading.value = false
+ }
+ }
+
+ /** 关键词搜索:防抖后重置到首页加载 */
+ function search(text) {
+ window.clearTimeout(debounceTimer)
+ debounceTimer = window.setTimeout(() => {
+ keyword.value = String(text ?? '')
+ load(true)
+ }, searchDelay)
+ }
+
+ /** 立即加载(展开/聚焦时用,不防抖):无关键词且已有首页缓存则先回填再静默刷新 */
+ function immediate(text) {
+ window.clearTimeout(debounceTimer)
+ keyword.value = String(text ?? '')
+ if (!keyword.value && defaultRows.value.length) {
+ rows.value = [...defaultRows.value]
+ total.value = defaultTotal.value
+ load(true, true)
+ return
+ }
+ load(true)
+ }
+
+ /** 滚动触底加载下一页 */
+ function loadMore() {
+ if (loading.value) return
+ if (rows.value.length >= total.value) return
+ currentPage++
+ load(false)
+ }
+
+ /** 清空数据/关键词/分页;clearCache=true 一并清掉首页缓存 */
+ function reset(clearCache = false) {
+ window.clearTimeout(debounceTimer)
+ requestVersion++
+ rows.value = []
+ total.value = 0
+ keyword.value = ''
+ currentPage = 1
+ loading.value = false
+ if (clearCache) {
+ defaultRows.value = []
+ defaultTotal.value = 0
+ }
+ }
+
+ // 关键配置变化时重置并清缓存(换了目标表/字段,旧数据不再有效)
+ watch(
+ () => [viewName, labelKey, valueKey, orderBy, searchCondition, searchColumns],
+ () => reset(true),
+ )
+
+ return { rows, loading, total, keyword, search, immediate, loadMore, reset }
+}
diff --git a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue
index fc09b2a7..f137ebc8 100644
--- a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue
+++ b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue
@@ -1,5 +1,15 @@
+
{
@click="onMaskClick"
>
-
+
{
- // 聚焦菜单容器承接键盘导航;浮层由 v-if + Transition 渲染,nextTick 后已挂载
+ // 聚焦菜单容器承接键盘导航;浮层由 v-if 渲染,nextTick 后已挂载
menuEl.value?.focus()
})
} else {
diff --git a/code/fms/fms-vue/src/components/ui/dropdown/index.scss b/code/fms/fms-vue/src/components/ui/dropdown/index.scss
index 06ec9854..7498c8bc 100644
--- a/code/fms/fms-vue/src/components/ui/dropdown/index.scss
+++ b/code/fms/fms-vue/src/components/ui/dropdown/index.scss
@@ -45,7 +45,8 @@
text-align: left;
white-space: nowrap;
cursor: pointer;
- transition: background-color 150ms ease;
+ /* 悬停 / 键盘高亮底色平滑切换:只变色,用 standard 缓动(浮层本身的进出场由 Popover 统一承担) */
+ transition: background-color var(--fms-duration-fast) var(--fms-ease-standard);
:deep(svg) {
flex-shrink: 0;
@@ -130,9 +131,3 @@
margin: var(--fms-dropdown-divider-margin);
background: var(--fms-border);
}
-
-@media (prefers-reduced-motion: reduce) {
- .dropdown-item {
- transition: none;
- }
-}
diff --git a/code/fms/fms-vue/src/components/ui/form/FormGroup.vue b/code/fms/fms-vue/src/components/ui/form/FormGroup.vue
index 25384dd3..05604cb3 100644
--- a/code/fms/fms-vue/src/components/ui/form/FormGroup.vue
+++ b/code/fms/fms-vue/src/components/ui/form/FormGroup.vue
@@ -95,8 +95,13 @@ function toggleCollapse() {
:class="{ 'is-collapsed': !expanded }"
/>
-
@@ -130,7 +135,6 @@ function toggleCollapse() {
min-width: 0;
padding-left: 8px;
border-left: 3px solid var(--fms-primary);
- transition: color 150ms ease;
}
/* plain 风格:纯文字,无竖线;可折叠时图标仍贴右 */
@@ -143,13 +147,34 @@ function toggleCollapse() {
flex: none;
margin-left: 8px;
color: var(--fms-text-secondary);
- transition: transform 250ms ease;
+ /* 展开 / 收起时箭头翻转,短过渡避免硬跳 */
+ transition: transform var(--fms-duration-fast) var(--fms-ease-standard);
}
.fms-form-group__collapse-icon.is-collapsed {
transform: rotate(-90deg);
}
+/* ========== 折叠外壳 ========== */
+/* 单行 grid,靠 grid-template-rows 在 1fr / 0fr 之间过渡实现高度动画。
+ 用 grid 而不是 height:height: auto 不可过渡;也不用 v-show 的 display 切换,
+ 那样过渡会直接失效。 */
+.fms-form-group__collapse {
+ display: grid;
+ grid-template-rows: 1fr;
+ transition: grid-template-rows var(--fms-duration-base) var(--fms-ease-standard);
+}
+
+.fms-form-group__collapse.is-collapsed {
+ grid-template-rows: 0fr;
+}
+
+/* 行高被压缩时子项要真能收起来:min-height: 0(允许被压)+ overflow: hidden(裁掉溢出) */
+.fms-form-group__collapse > * {
+ min-height: 0;
+ overflow: hidden;
+}
+
/* ========== 组内宫格 ========== */
.fms-form-group__grid {
display: grid;
@@ -161,4 +186,4 @@ function toggleCollapse() {
min-width: 0;
}
}
-
\ No newline at end of file
+
diff --git a/code/fms/fms-vue/src/components/ui/form/form.vue b/code/fms/fms-vue/src/components/ui/form/form.vue
index abf1f710..f7200948 100644
--- a/code/fms/fms-vue/src/components/ui/form/form.vue
+++ b/code/fms/fms-vue/src/components/ui/form/form.vue
@@ -148,10 +148,7 @@ defineExpose({ validateAll })