feat: restore admin form quality lightly

This commit is contained in:
2026-06-12 09:01:27 +08:00
parent 8cb1101f5a
commit faa14fd3dd
5 changed files with 499 additions and 4 deletions
@@ -100,6 +100,11 @@ const RoleSelector = defineAsyncComponent(() =>
(res) => res.default,
),
);
const DeptSelector = defineAsyncComponent(() =>
import('#/components/zq-form/dept-selector/dept-selector.vue').then(
(res) => res.default,
),
);
const FileSelector = defineAsyncComponent(() =>
import('#/components/zq-form/file-selector/file-selector.vue').then(
(res) => res.default,
@@ -115,6 +120,11 @@ const UserSelector = defineAsyncComponent(() =>
(res) => res.default,
),
);
const PostSelector = defineAsyncComponent(() =>
import('#/components/zq-form/post-selector/post-selector.vue').then(
(res) => res.default,
),
);
const ZqIconPicker = defineAsyncComponent(() =>
import('#/components/zq-form/zq-icon-picker/zq-icon-picker.vue').then(
(res) => res.default,
@@ -166,12 +176,14 @@ export type ComponentType =
| 'CheckboxGroup'
| 'CodeEditor'
| 'DatePicker'
| 'DeptSelector'
| 'Divider'
| 'FileSelector'
| 'IconPicker'
| 'ImageSelector'
| 'Input'
| 'InputNumber'
| 'PostSelector'
| 'RadioGroup'
| 'RoleSelector'
| 'Select'
@@ -249,6 +261,7 @@ function initComponentAdapter() {
PrimaryButton: (props, { attrs, slots }) => {
return h(ElButton, { ...props, attrs, type: 'primary' }, slots);
},
DeptSelector: withDefaultPlaceholder(DeptSelector, 'select'),
RoleSelector: withDefaultPlaceholder(RoleSelector, 'select'),
Divider: ElDivider,
FileSelector: withDefaultPlaceholder(FileSelector, 'select'),
@@ -261,6 +274,7 @@ function initComponentAdapter() {
type: 'textarea',
}),
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
PostSelector: withDefaultPlaceholder(PostSelector, 'select'),
RadioGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {
@@ -0,0 +1 @@
export { default as RichTextEditor } from './rich-text-editor.vue';
@@ -0,0 +1,458 @@
<script lang="ts" setup>
import { nextTick, ref, watch } from 'vue';
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Eraser,
Heading1,
Image,
Italic,
Link,
List,
ListOrdered,
Quote,
Redo2,
Table,
Underline,
Undo2,
} from '@vben/icons';
import {
ElButton,
ElButtonGroup,
ElColorPicker,
ElDivider,
ElInput,
ElMessage,
ElPopover,
ElSelect,
ElOption,
} from 'element-plus';
const props = withDefaults(
defineProps<{
disabled?: boolean;
maxHeight?: number;
minHeight?: number;
modelValue?: string;
placeholder?: string;
readonly?: boolean;
}>(),
{
disabled: false,
maxHeight: 500,
minHeight: 200,
modelValue: '',
placeholder: '',
readonly: false,
},
);
const emit = defineEmits<{
blur: [event: FocusEvent];
change: [value: string];
focus: [event: FocusEvent];
'update:modelValue': [value: string];
}>();
const editorRef = ref<HTMLElement>();
const linkPopoverVisible = ref(false);
const imagePopoverVisible = ref(false);
const tablePopoverVisible = ref(false);
const linkUrl = ref('');
const imageUrl = ref('');
const tableRows = ref(3);
const tableCols = ref(3);
const currentColor = ref('#1f2937');
const currentBlock = ref('P');
let isSyncing = false;
function syncHtml(value = props.modelValue || '') {
if (!editorRef.value || editorRef.value.innerHTML === value) return;
isSyncing = true;
editorRef.value.innerHTML = value;
nextTick(() => {
isSyncing = false;
});
}
watch(
() => props.modelValue,
(value) => syncHtml(value || ''),
);
function emitChange() {
if (isSyncing || !editorRef.value) return;
const html = editorRef.value.innerHTML;
emit('update:modelValue', html);
emit('change', html);
}
function focusEditor() {
editorRef.value?.focus();
}
function exec(command: string, value?: string) {
if (props.disabled || props.readonly) return;
focusEditor();
document.execCommand(command, false, value);
emitChange();
}
function applyBlock(value: string) {
currentBlock.value = value;
exec('formatBlock', value);
}
function applyColor(value: string | null) {
if (!value) return;
currentColor.value = value;
exec('foreColor', value);
}
function insertLink() {
const url = linkUrl.value.trim();
if (!url) {
ElMessage.warning('请输入链接地址');
return;
}
exec('createLink', url);
linkPopoverVisible.value = false;
linkUrl.value = '';
}
function insertImage() {
const url = imageUrl.value.trim();
if (!url) {
ElMessage.warning('请输入图片地址');
return;
}
exec('insertHTML', `<img src="${url}" alt="" />`);
imagePopoverVisible.value = false;
imageUrl.value = '';
}
function insertTable() {
const rows = Math.max(1, Math.min(10, tableRows.value));
const cols = Math.max(1, Math.min(8, tableCols.value));
const cells = Array.from({ length: cols })
.map(() => '<td><br></td>')
.join('');
const body = Array.from({ length: rows })
.map(() => `<tr>${cells}</tr>`)
.join('');
exec('insertHTML', `<table><tbody>${body}</tbody></table><p><br></p>`);
tablePopoverVisible.value = false;
}
function handlePaste(event: ClipboardEvent) {
const html = event.clipboardData?.getData('text/html');
const text = event.clipboardData?.getData('text/plain');
if (!html && !text) return;
event.preventDefault();
exec('insertHTML', html || (text || '').replaceAll('\n', '<br>'));
}
function handleFocus(event: FocusEvent) {
emit('focus', event);
}
function handleBlur(event: FocusEvent) {
emitChange();
emit('blur', event);
}
nextTick(() => syncHtml());
</script>
<template>
<div class="rich-text-editor" :class="{ 'is-disabled': disabled || readonly }">
<div class="rich-text-toolbar">
<ElSelect
v-model="currentBlock"
size="small"
class="block-select"
:disabled="disabled || readonly"
@change="applyBlock"
>
<ElOption label="正文" value="P" />
<ElOption label="标题 1" value="H1" />
<ElOption label="标题 2" value="H2" />
<ElOption label="标题 3" value="H3" />
</ElSelect>
<ElDivider direction="vertical" />
<ElButtonGroup>
<ElButton
:icon="Undo2"
size="small"
text
title="撤销"
@click="exec('undo')"
/>
<ElButton
:icon="Redo2"
size="small"
text
title="重做"
@click="exec('redo')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElButtonGroup>
<ElButton
:icon="Bold"
size="small"
text
title="加粗"
@click="exec('bold')"
/>
<ElButton
:icon="Italic"
size="small"
text
title="斜体"
@click="exec('italic')"
/>
<ElButton
:icon="Underline"
size="small"
text
title="下划线"
@click="exec('underline')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElButtonGroup>
<ElButton
:icon="AlignLeft"
size="small"
text
title="左对齐"
@click="exec('justifyLeft')"
/>
<ElButton
:icon="AlignCenter"
size="small"
text
title="居中"
@click="exec('justifyCenter')"
/>
<ElButton
:icon="AlignRight"
size="small"
text
title="右对齐"
@click="exec('justifyRight')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElButtonGroup>
<ElButton
:icon="List"
size="small"
text
title="无序列表"
@click="exec('insertUnorderedList')"
/>
<ElButton
:icon="ListOrdered"
size="small"
text
title="有序列表"
@click="exec('insertOrderedList')"
/>
<ElButton
:icon="Quote"
size="small"
text
title="引用"
@click="exec('formatBlock', 'BLOCKQUOTE')"
/>
</ElButtonGroup>
<ElDivider direction="vertical" />
<ElColorPicker
v-model="currentColor"
size="small"
:disabled="disabled || readonly"
@change="applyColor"
/>
<ElDivider direction="vertical" />
<ElPopover v-model:visible="linkPopoverVisible" width="300" trigger="click">
<template #reference>
<ElButton :icon="Link" size="small" text title="插入链接" />
</template>
<div class="popover-form">
<ElInput v-model="linkUrl" placeholder="https://example.com" />
<ElButton type="primary" size="small" @click="insertLink">插入链接</ElButton>
</div>
</ElPopover>
<ElPopover v-model:visible="imagePopoverVisible" width="300" trigger="click">
<template #reference>
<ElButton :icon="Image" size="small" text title="插入图片" />
</template>
<div class="popover-form">
<ElInput v-model="imageUrl" placeholder="图片 URL" />
<ElButton type="primary" size="small" @click="insertImage">插入图片</ElButton>
</div>
</ElPopover>
<ElPopover v-model:visible="tablePopoverVisible" width="260" trigger="click">
<template #reference>
<ElButton :icon="Table" size="small" text title="插入表格" />
</template>
<div class="table-popover">
<ElInput v-model.number="tableRows" type="number" min="1" max="10">
<template #prepend></template>
</ElInput>
<ElInput v-model.number="tableCols" type="number" min="1" max="8">
<template #prepend></template>
</ElInput>
<ElButton type="primary" size="small" @click="insertTable">插入表格</ElButton>
</div>
</ElPopover>
<ElButton
:icon="Eraser"
size="small"
text
title="清除格式"
@click="exec('removeFormat')"
/>
<ElButton
:icon="Heading1"
size="small"
text
title="标题"
@click="applyBlock('H2')"
/>
</div>
<div
ref="editorRef"
class="rich-text-content"
:contenteditable="!disabled && !readonly"
:data-placeholder="placeholder"
:style="{ minHeight: `${minHeight}px`, maxHeight: `${maxHeight}px` }"
role="textbox"
aria-multiline="true"
@blur="handleBlur"
@focus="handleFocus"
@input="emitChange"
@paste="handlePaste"
></div>
</div>
</template>
<style scoped>
.rich-text-editor {
border: 1px solid var(--el-border-color);
border-radius: 6px;
background: var(--el-bg-color);
}
.rich-text-editor.is-disabled {
opacity: 0.72;
}
.rich-text-toolbar {
display: flex;
min-height: 42px;
align-items: center;
gap: 2px;
flex-wrap: wrap;
border-bottom: 1px solid var(--el-border-color-lighter);
padding: 5px 8px;
}
.block-select {
width: 96px;
}
.rich-text-content {
overflow: auto;
padding: 12px 14px;
outline: none;
line-height: 1.7;
}
.rich-text-content:empty::before {
color: var(--el-text-color-placeholder);
content: attr(data-placeholder);
}
.rich-text-content :deep(h1),
.rich-text-content :deep(h2),
.rich-text-content :deep(h3) {
margin: 0.8em 0 0.45em;
font-weight: 650;
line-height: 1.35;
}
.rich-text-content :deep(h1) {
font-size: 24px;
}
.rich-text-content :deep(h2) {
font-size: 20px;
}
.rich-text-content :deep(h3) {
font-size: 17px;
}
.rich-text-content :deep(p) {
margin: 0.45em 0;
}
.rich-text-content :deep(blockquote) {
margin: 8px 0;
border-left: 4px solid var(--el-border-color);
padding-left: 14px;
color: var(--el-text-color-secondary);
}
.rich-text-content :deep(img) {
max-width: 100%;
border-radius: 4px;
}
.rich-text-content :deep(table) {
width: 100%;
margin: 8px 0;
border-collapse: collapse;
}
.rich-text-content :deep(td),
.rich-text-content :deep(th) {
min-width: 48px;
border: 1px solid var(--el-border-color);
padding: 6px 8px;
}
.rich-text-content :deep(a) {
color: var(--el-color-primary);
text-decoration: underline;
}
.popover-form,
.table-popover {
display: grid;
gap: 8px;
}
</style>
@@ -60,6 +60,7 @@ import {
import { FuPage } from '#/components/fu-page';
import { UserAvatar } from '#/components/user-avatar';
import { ZqDrawer } from '#/components/zq-drawer';
import { RichTextEditor } from '#/components/zq-form/rich-text-editor';
defineOptions({ name: 'AnnouncementManager' });
@@ -810,13 +811,12 @@ onBeforeUnmount(() => {
prop="content"
required
>
<ElInput
<RichTextEditor
v-model="formData.content"
class="w-full"
type="textarea"
:placeholder="$t('announcement.formContentPlaceholder')"
:autosize="{ minRows: 8, maxRows: 16 }"
resize="vertical"
:min-height="220"
:max-height="520"
/>
</ElFormItem>
</ElForm>
@@ -191,6 +191,14 @@ export function getFormSchema(): VbenFormSchema[] {
fieldName: 'bio',
label: $t('user.bio'),
},
{
component: 'DeptSelector',
componentProps: {
placeholder: $t('user.selectDept'),
},
fieldName: 'dept_id',
label: $t('user.dept'),
},
{
component: 'UserSelector',
componentProps: {
@@ -199,6 +207,14 @@ export function getFormSchema(): VbenFormSchema[] {
fieldName: 'manager_id',
label: $t('user.manager'),
},
{
component: 'PostSelector',
componentProps: {
placeholder: $t('user.selectPost'),
},
fieldName: 'post_id',
label: $t('user.post'),
},
{
component: 'RoleSelector',
componentProps: {
@@ -295,6 +311,12 @@ export function useZqTableColumns(): Column[] {
title: $t('user.city'),
width: 120,
},
{
key: 'dept_name',
dataKey: 'dept_name',
title: $t('user.dept'),
width: 140,
},
{
key: 'manager_name',
dataKey: 'manager_name',