后台右上角增加快速创建图标并实现快速创建pagetype记录的功能

This commit is contained in:
jingrow 2026-04-22 00:14:04 +08:00
parent 7dde2c4026
commit 535c17bd9b
9 changed files with 377 additions and 4 deletions

View File

@ -55,6 +55,9 @@
/>
</div>
<!-- 创建 -->
<QuickCreate />
<!-- 通知 -->
<NotificationDropdown />
@ -74,6 +77,7 @@ import { useAuthStore } from '../../shared/stores/auth'
import { t } from '../../shared/i18n'
import UserMenu from '../../shared/components/UserMenu.vue'
import NotificationDropdown from '../../shared/components/NotificationDropdown.vue'
import QuickCreate from '../../shared/components/QuickCreate.vue'
import AwesomeBarDropdown from '../../shared/components/AwesomeBarDropdown.vue'
import { useAwesomeBar } from '../../shared/composables/useAwesomeBar'

View File

@ -141,6 +141,16 @@ export const getPageTypeMeta = async (pagetype: string): Promise<{ success: bool
}
}
// Get quick entry fields for a PageType
export const getQuickEntryFields = async (pagetype: string): Promise<{ success: boolean; data?: any[]; message?: string }> => {
try {
const result = await api.call('jingrow.client.get_quick_entry_fields', { pagetype })
return { success: true, data: result.message || [] }
} catch (error: any) {
return { success: false, data: [], message: error.message || t('Failed to get quick entry fields') }
}
}
// Get record attachments list
export const getRecordAttachments = async (pagetype: string, name: string): Promise<{ success: boolean; data?: any[]; message?: string }> => {
try {

View File

@ -0,0 +1,298 @@
<template>
<n-popover
trigger="click"
placement="bottom-end"
:show="showPopover"
@update:show="handlePopoverUpdate"
:show-arrow="false"
:style="{ padding: 0 }"
raw
>
<template #trigger>
<n-button quaternary circle>
<template #icon>
<Icon icon="tabler:plus" :size="20" />
</template>
</n-button>
</template>
<div class="quick-create">
<!-- Menu View -->
<template v-if="view === 'menu'">
<div class="qc-header">{{ t('Quick Create') }}</div>
<div class="qc-menu">
<div
v-for="item in menuItems"
:key="item.label"
class="qc-menu-item"
@click="selectPageType(item)"
>
<Icon :icon="item.icon" :size="18" />
<span>{{ item.label }}</span>
</div>
</div>
</template>
<!-- Form View -->
<template v-else>
<div class="qc-header">
<n-button text size="small" @click="backToMenu">
<template #icon><Icon icon="tabler:arrow-left" :size="16" /></template>
</n-button>
<span class="qc-header-title">{{ t('New {0}', [selectedLabel]) }}</span>
<n-button
type="primary"
size="small"
:loading="submitting"
@click="handleSubmit"
class="save-btn-brand"
>
<template #icon><Icon icon="tabler:check" :size="14" /></template>
{{ t('Save') }}
</n-button>
</div>
<div v-if="loadingFields" class="qc-loading">
<n-spin size="small" />
</div>
<div v-else class="qc-form">
<div v-for="field in fields" :key="field.fieldname" class="qc-field">
<FieldRenderer
:df="field"
:record="formData"
:can-edit="true"
:ctx="fieldCtx"
/>
</div>
</div>
</template>
</div>
</n-popover>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick } from 'vue'
import { NPopover, NButton, NSpin, useMessage } from 'naive-ui'
import { Icon } from '@iconify/vue'
import FieldRenderer from '@/core/pagetype/form/FieldRenderer.vue'
import { getQuickEntryFields, createRecord } from '../api/common'
import { t } from '../i18n'
const message = useMessage()
const showPopover = ref(false)
const view = ref<'menu' | 'form'>('menu')
const selectedPageType = ref('')
const selectedLabel = ref('')
const fields = ref<any[]>([])
const formData = reactive<Record<string, any>>({})
const loadingFields = ref(false)
const submitting = ref(false)
// Minimal ctx for FieldRenderer
const fieldCtx: Record<string, any> = {
t,
entity: '',
getSelectOptions(field: any) {
if (!field.options) return []
return field.options.split('\n').filter((s: string) => s.trim() !== '').map((opt: string) => ({ label: t(opt), value: opt }))
},
}
const menuItems = [
{ label: 'Activity', icon: 'tabler:activity' },
{ label: 'ToDo', icon: 'tabler:checklist' },
{ label: 'Knowledge Base', icon: 'tabler:book' },
{ label: 'Note', icon: 'tabler:note' },
]
const handlePopoverUpdate = (show: boolean) => {
showPopover.value = show
}
const selectPageType = async (item: { label: string; icon: string }) => {
selectedPageType.value = item.label
selectedLabel.value = item.label
fieldCtx.entity = item.label
view.value = 'form'
loadingFields.value = true
// Reset form data
Object.keys(formData).forEach(k => delete formData[k])
const result = await getQuickEntryFields(item.label)
if (result.success && result.data) {
fields.value = result.data
// Set defaults
for (const field of result.data) {
if (field.default) {
if (field.fieldtype === 'Check') {
formData[field.fieldname] = field.default ? 1 : 0
} else if (field.fieldtype === 'Date' && field.default === 'Today') {
formData[field.fieldname] = new Date().toISOString().split('T')[0]
} else {
formData[field.fieldname] = field.default
}
}
}
} else {
message.error(result.message || t('Failed to load fields'))
}
loadingFields.value = false
}
const backToMenu = () => {
view.value = 'menu'
fields.value = []
Object.keys(formData).forEach(k => delete formData[k])
}
const handleSubmit = async () => {
// Validate required fields
for (const field of fields.value) {
if (field.reqd && !formData[field.fieldname]) {
message.warning(t('{0} is required', [field.label]))
return
}
}
submitting.value = true
try {
// Convert Check boolean to int
const data = { ...formData }
for (const field of fields.value) {
if (field.fieldtype === 'Check') {
data[field.fieldname] = data[field.fieldname] ? 1 : 0
}
}
const result = await createRecord(selectedPageType.value, data)
if (result.success) {
message.success(t('{0} created successfully', [selectedLabel.value]))
nextTick(() => {
showPopover.value = false
backToMenu()
})
} else {
message.error(result.message || t('Failed to create record'))
}
} catch (error: any) {
message.error(error.message || t('Failed to create record'))
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.quick-create {
width: 600px;
background: var(--n-color);
border-radius: 8px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
.qc-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-weight: 500;
font-size: 14px;
border-bottom: 1px solid var(--n-border-color, #f0f0f0);
}
.qc-header-title {
flex: 1;
}
/* 保存按钮 - 与详情页风格一致 */
.save-btn-brand {
background: #e6f8f0 !important;
border: 1px solid #1fc76f !important;
color: #0d684b !important;
}
.save-btn-brand :deep(.n-button__border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand:hover {
background: #dcfce7 !important;
border: 1px solid #1fc76f !important;
color: #166534 !important;
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15) !important;
}
.save-btn-brand:hover :deep(.n-button__border),
.save-btn-brand:hover :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand:focus {
background: #dcfce7 !important;
border: 1px solid #1fc76f !important;
color: #166534 !important;
box-shadow: 0 0 0 2px rgba(31, 199, 111, 0.2) !important;
}
.save-btn-brand:focus :deep(.n-button__border),
.save-btn-brand:focus :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand:active {
background: #1fc76f !important;
border: 1px solid #1fc76f !important;
color: white !important;
}
.save-btn-brand:active :deep(.n-button__border),
.save-btn-brand:active :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
.qc-menu {
padding: 6px 0;
}
.qc-menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 14px;
}
.qc-menu-item:hover {
background-color: var(--n-color-hover, #f5f5f5);
}
.qc-loading {
display: flex;
justify-content: center;
padding: 40px 0;
}
.qc-form {
padding: 16px;
max-height: 520px;
overflow-y: auto;
}
.qc-field {
margin-bottom: 12px;
}
</style>

View File

@ -95,6 +95,30 @@ def get(pagetype, name=None, filters=None, parent=None):
return pg.as_dict()
@jingrow.whitelist()
def get_quick_entry_fields(pagetype):
"""Returns fields for quick entry form of given pagetype"""
meta = jingrow.get_meta(pagetype)
fields = []
for df in meta.fields:
if (
(df.reqd or df.allow_in_quick_entry)
and not df.read_only
and not df.is_virtual
and df.fieldtype not in ("Tab Break", "Section Break", "Column Break")
):
fields.append({
"fieldname": df.fieldname,
"label": df.label,
"fieldtype": df.fieldtype,
"reqd": df.reqd or 0,
"options": df.options or "",
"default": df.default or "",
"description": df.description or "",
})
return fields
@jingrow.whitelist()
def get_value(pagetype, fieldname, filters=None, as_dict=True, debug=False, parent=None):
"""Returns a value form a document

View File

@ -17,6 +17,7 @@
],
"fields": [
{
"allow_in_quick_entry": 1,
"fieldname": "title",
"fieldtype": "Data",
"in_global_search": 1,
@ -30,6 +31,7 @@
"fieldtype": "Column Break"
},
{
"allow_in_quick_entry": 1,
"default": "Note",
"fieldname": "activity_type",
"fieldtype": "Select",
@ -44,6 +46,7 @@
"fieldtype": "Section Break"
},
{
"allow_in_quick_entry": 1,
"fieldname": "content",
"fieldtype": "Jeditor",
"label": "Content"
@ -76,7 +79,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-04-20 21:35:36.193833",
"modified": "2026-04-21 23:42:39.213855",
"modified_by": "Administrator",
"module": "Core",
"name": "Activity",

View File

@ -12,6 +12,7 @@
],
"fields": [
{
"allow_in_quick_entry": 1,
"fieldname": "title",
"fieldtype": "Data",
"in_filter": 1,
@ -21,11 +22,13 @@
"label": "Title"
},
{
"allow_in_quick_entry": 1,
"fieldname": "content",
"fieldtype": "Jeditor",
"label": "Content"
},
{
"allow_in_quick_entry": 1,
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
@ -42,6 +45,7 @@
"read_only": 1
},
{
"allow_in_quick_entry": 1,
"fieldname": "category",
"fieldtype": "Link",
"in_list_view": 1,
@ -53,7 +57,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-09-01 22:31:27.311442",
"modified": "2026-04-21 23:43:13.225221",
"modified_by": "Administrator",
"module": "Desk",
"name": "Knowledge Base",

View File

@ -16,6 +16,7 @@
],
"fields": [
{
"allow_in_quick_entry": 1,
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
@ -59,6 +60,7 @@
"search_index": 1
},
{
"allow_in_quick_entry": 1,
"bold": 1,
"description": "Help: To link to another record in the system, use \"/app/note/[Note Name]\" as the Link URL. (don't use \"http://\")",
"fieldname": "content",
@ -85,7 +87,7 @@
"icon": "fa fa-file-text",
"idx": 1,
"links": [],
"modified": "2025-09-01 14:07:24.540571",
"modified": "2026-04-21 23:44:37.725453",
"modified_by": "Administrator",
"module": "Desk",
"name": "Note",

View File

@ -42,6 +42,7 @@
"options": "Todo\nIn Progress\nReview\nDone\nCancelled"
},
{
"allow_in_quick_entry": 1,
"default": "Medium",
"fieldname": "priority",
"fieldtype": "Select",
@ -77,6 +78,7 @@
"fieldtype": "Section Break"
},
{
"allow_in_quick_entry": 1,
"fieldname": "description",
"fieldtype": "Jeditor",
"in_global_search": 1,
@ -148,6 +150,7 @@
"read_only": 1
},
{
"allow_in_quick_entry": 1,
"fieldname": "allocated_to",
"fieldtype": "Link",
"ignore_user_permissions": 1,
@ -158,8 +161,10 @@
"options": "User"
},
{
"allow_in_quick_entry": 1,
"fieldname": "title",
"fieldtype": "Data",
"in_global_search": 1,
"in_list_view": 1,
"label": "Title",
"reqd": 1
@ -168,7 +173,7 @@
"icon": "fa fa-check",
"idx": 2,
"links": [],
"modified": "2026-04-21 21:31:33.885406",
"modified": "2026-04-21 23:41:45.406544",
"modified_by": "Administrator",
"module": "Desk",
"name": "ToDo",

View File

@ -35761,3 +35761,26 @@ msgstr "下载备份"
msgid "No ToDos"
msgstr "没有待办事项"
# Quick Create
msgid "Quick Create"
msgstr "快速创建"
msgid "New {0}"
msgstr "新建{0}"
msgid "{0} is required"
msgstr "{0}为必填项"
msgid "{0} created successfully"
msgstr "{0}创建成功"
msgid "Failed to load fields"
msgstr "加载字段失败"
msgid "Failed to create record"
msgstr "创建记录失败"
msgid "Failed to get quick entry fields"
msgstr "获取快速创建字段失败"