add registration event sort

This commit is contained in:
Timi
2026-08-03 15:34:18 +08:00
parent c49e01be38
commit b30d0d1769
6 changed files with 533 additions and 83 deletions
+5
View File
@@ -25,6 +25,10 @@ async function update(req: RegistrationEvent): Promise<void> {
return await axios.post(`${BASE_URI}/update`, req);
}
async function sort(idList: string[]): Promise<void> {
return await axios.post(`${BASE_URI}/sort`, idList);
}
async function remove(id: string): Promise<void> {
return await axios.post(`${BASE_URI}/delete`, undefined, {
params: { id }
@@ -37,5 +41,6 @@ export default {
detail,
create,
update,
sort,
remove
};
+143 -80
View File
@@ -1,12 +1,6 @@
<template>
<div v-can="pageAccess" class="registration-event-edit">
<t-form class="form" label-align="left">
<t-form-item label="名称">
<t-input v-model.trim="formModel.name" placeholder="登记事件名称" borderless />
</t-form-item>
<t-form-item label="说明">
<t-textarea v-model.trim="formModel.description" class="textarea" placeholder="事件说明" autosize borderless />
</t-form-item>
<t-form-item label="颜色">
<t-button
size="extra-small"
@@ -16,44 +10,59 @@
>
<div class="content">
<span class="swatch" :style="{ background: formModel.color }" />
<span v-text="formModel.color" />
<span v-text="formModel.color.toLowerCase()" />
</div>
</t-button>
</t-form-item>
<t-form-item label="状态">
<t-radio-group v-model="formModel.status" class="inline-options" variant="default-filled">
<t-radio :block="false" borderless value="DRAFT">草稿</t-radio>
<t-radio :block="false" borderless value="ACTIVE">可登记</t-radio>
<t-radio :block="false" borderless value="INACTIVE">不可登记</t-radio>
</t-radio-group>
<t-form-item label="名称">
<t-input
class="name"
v-model.trim="formModel.name"
placeholder="登记事件名称"
borderless
/>
</t-form-item>
<t-form-item label="说明">
<t-textarea
class="description"
v-model.trim="formModel.description"
placeholder="事件说明"
borderless
/>
</t-form-item>
<t-form-item label="起始时间" arrow @click="openBeginAtPicker">
<div class="begin-at">
<div class="picker-text" v-text="formModel.beginAtText || '不限'" />
<t-button
v-if="formModel.beginAtText"
size="extra-small"
content="不限"
@click.stop="clearBeginAt"
/>
</div>
</t-form-item>
<t-form-item label="终止时间" arrow @click="openEndAtPicker">
<div class="end-at">
<div class="picker-text" v-text="formModel.endAtText || '不限'" />
<t-button
v-if="formModel.endAtText"
size="extra-small"
content="不限"
@click.stop="clearEndAt"
/>
</div>
</t-form-item>
<t-form-item label="约束" arrow @click="openLimitPicker">
<div class="picker-text" v-text="getLimitText(formModel.limitType)" />
</t-form-item>
<t-form-item v-if="isValueLimitType(formModel.limitType)" label="约束数值">
<t-input v-model.trim="formModel.limitValueText" type="number" :placeholder="limitValuePlaceholder" borderless />
</t-form-item>
<t-form-item label="需要拍照" content-align="right">
<t-switch v-model="formModel.requirePhoto" />
</t-form-item>
<t-form-item label="排序">
<t-input v-model.trim="formModel.sortText" type="number" placeholder="排序值" borderless />
</t-form-item>
<t-form-item label="约束">
<t-radio-group v-model="formModel.limitType" class="limit-options" variant="default-filled">
<t-radio
v-for="option in limitOptions"
:key="option.value"
:block="false"
borderless
:value="option.value"
>
<span v-text="option.label" />
</t-radio>
</t-radio-group>
</t-form-item>
<t-form-item v-if="isValueLimitType(formModel.limitType)" label="数值">
<t-input v-model.trim="formModel.limitValueText" type="number" :placeholder="limitValuePlaceholder" borderless />
</t-form-item>
<t-form-item label="起始时间" arrow @click="openBeginAtPicker">
<div class="picker-text" v-text="formModel.beginAtText || '不限'" />
</t-form-item>
<t-form-item label="终止时间" arrow @click="openEndAtPicker">
<div class="picker-text" v-text="formModel.endAtText || '不限'" />
<t-form-item label="状态" arrow @click="openStatusPicker">
<div class="picker-text" v-text="getStatusText(formModel.status)" />
</t-form-item>
</t-form>
<section class="roles">
@@ -113,13 +122,26 @@
/>
</div>
</t-popup>
<t-popup v-model="limitPickerVisible" placement="bottom">
<t-picker
v-model="limitPickerDraft"
:columns="limitPickerColumns"
title="约束"
@cancel="limitPickerVisible = false"
@confirm="confirmLimitPicker"
/>
</t-popup>
<t-popup v-model="statusPickerVisible" placement="bottom">
<t-picker
v-model="statusPickerDraft"
:columns="statusPickerColumns"
title="状态"
@cancel="statusPickerVisible = false"
@confirm="confirmStatusPicker"
/>
</t-popup>
<t-popup v-model="beginAtPickerVisible" placement="bottom">
<div class="picker-popup">
<div class="picker-header">
<t-button variant="text" size="small" @click="clearBeginAt">清空</t-button>
<span v-text="'起始时间'" />
<t-button variant="text" size="small" @click="beginAtPickerVisible = false">取消</t-button>
</div>
<t-date-time-picker
v-model="beginAtDraftText"
:default-value="beginAtDraftText || nowDateTimeText"
@@ -133,11 +155,6 @@
</t-popup>
<t-popup v-model="endAtPickerVisible" placement="bottom">
<div class="picker-popup">
<div class="picker-header">
<t-button variant="text" size="small" @click="clearEndAt">清空</t-button>
<span v-text="'终止时间'" />
<t-button variant="text" size="small" @click="endAtPickerVisible = false">取消</t-button>
</div>
<t-date-time-picker
v-model="endAtDraftText"
:default-value="endAtDraftText || nowDateTimeText"
@@ -176,9 +193,7 @@ const isCreateMode = computed(() => !routeId.value);
const savePermissionCode = computed(() => isCreateMode.value ? gaoPermissionCodes.registrationEvent.create : gaoPermissionCodes.registrationEvent.update);
const deletePermissionCode = gaoPermissionCodes.registrationEvent.delete;
const pageAccess = computed(() => ({
value: isCreateMode.value
? [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read, savePermissionCode.value]
: [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read, { value: [savePermissionCode.value, deletePermissionCode], mode: "or" as const }],
value: [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read, savePermissionCode.value],
mode: "and" as const
}));
const roleOptions = ref<Role[]>([]);
@@ -191,40 +206,50 @@ const endAtDraftText = ref("");
const saving = ref(false);
const removing = ref(false);
const colorSwatchList = [
"#2f88ff",
"#0ea5e9",
"#10b981",
"#22c55e",
"#f59e0b",
"#f97316",
"#ef4444",
"#ec4899",
"#8b5cf6",
"#64748b"
"#2F88FF",
"#0EA5E9",
"#10B981",
"#22C55E",
"#F59E0B",
"#F97316",
"#EF4444",
"#EC4899",
"#8B5CF6",
"#64748B"
];
const limitOptions: LimitOption[] = [
{ label: "不限", value: "NONE" },
{ label: "上午/下午各一次", value: "DAILY_HALF_DAY" },
{ label: "每天一次", value: "NATURAL_DAY" },
{ label: "间隔小时", value: "INTERVAL_HOUR" },
{ label: "每月一次", value: "NATURAL_MONTH" },
{ label: "上午和下午各一次", value: "DAILY_HALF_DAY" },
{ label: "间隔小时内一次", value: "INTERVAL_HOUR" },
{ label: "每个自然日一次", value: "NATURAL_DAY" },
{ label: "每个自然月一次", value: "NATURAL_MONTH" },
{ label: "事件总次数", value: "TOTAL_LIMIT" },
{ label: "单客户总次数", value: "CUSTOMER_TOTAL_LIMIT" }
];
const statusOptions: Array<{ label: string; value: RegistrationEventStatus }> = [
{ label: "草稿", value: "DRAFT" },
{ label: "可登记", value: "ACTIVE" },
{ label: "不可登记", value: "INACTIVE" }
];
const limitPickerColumns = [limitOptions];
const statusPickerColumns = [statusOptions];
const formModel = reactive({
id: "",
name: "",
description: "",
color: "#2f88ff",
color: "#0f766e",
status: "ACTIVE" as RegistrationEventStatus,
requirePhoto: false,
limitType: "NONE" as RegistrationLimitType,
limitValueText: "",
beginAtText: "",
endAtText: "",
sortText: "0",
roleCodeList: [] as string[]
});
const limitPickerVisible = ref(false);
const limitPickerDraft = ref<string[]>([formModel.limitType]);
const statusPickerVisible = ref(false);
const statusPickerDraft = ref<RegistrationEventStatus[]>([formModel.status]);
const nowDateTimeText = computed(() => Time.formatDateTime(new Date()));
const limitValuePlaceholder = computed(() => {
if (formModel.limitType === "INTERVAL_HOUR") {
@@ -251,6 +276,40 @@ function confirmColorPicker(): void {
colorPickerVisible.value = false;
}
function getLimitText(value: RegistrationLimitType): string {
return limitOptions.find(option => option.value === value)?.label || "不限";
}
function getStatusText(value: RegistrationEventStatus): string {
return statusOptions.find(option => option.value === value)?.label || "可登记";
}
function openLimitPicker(): void {
limitPickerDraft.value = [formModel.limitType];
limitPickerVisible.value = true;
}
function confirmLimitPicker(value: Array<string | number>): void {
const limitType = value[0];
if (typeof limitType === "string") {
formModel.limitType = limitType as RegistrationLimitType;
}
limitPickerVisible.value = false;
}
function openStatusPicker(): void {
statusPickerDraft.value = [formModel.status];
statusPickerVisible.value = true;
}
function confirmStatusPicker(value: Array<string | number>): void {
const status = value[0];
if (status === "DRAFT" || status === "ACTIVE" || status === "INACTIVE") {
formModel.status = status;
}
statusPickerVisible.value = false;
}
function isValueLimitType(value: RegistrationLimitType): boolean {
return value === "INTERVAL_HOUR" || value === "TOTAL_LIMIT" || value === "CUSTOMER_TOTAL_LIMIT";
}
@@ -308,7 +367,6 @@ function buildPayload(): RegistrationEvent {
limitValue: resolveLimitValue(),
beginAt: resolveTimestamp(formModel.beginAtText),
endAt: resolveTimestamp(formModel.endAtText),
sort: Math.floor(Number(formModel.sortText) || 0),
roleCodeList: [...formModel.roleCodeList]
};
}
@@ -432,7 +490,6 @@ async function fillForm(): Promise<void> {
limitValueText: current.limitValue ? String(current.limitValue) : "",
beginAtText: current.beginAt ? Time.formatDateTime(new Date(current.beginAt)) : "",
endAtText: current.endAt ? Time.formatDateTime(new Date(current.endAt)) : "",
sortText: String(current.sort ?? 0),
roleCodeList: [...(current.roleCodeList || [])]
});
colorDraft.value = formModel.color;
@@ -453,6 +510,22 @@ onMounted(async () => {
.form {
border-radius: .75rem;
.description {
width: 100%;
height: 4rem;
}
.end-at,
.begin-at {
--td-button-extra-small-height: 24px;
gap: .5rem;
width: 100%;
display: flex;
align-items: center;
justify-content: end;
}
}
.picker-text {
@@ -461,10 +534,6 @@ onMounted(async () => {
text-align: right;
}
.textarea {
width: 100%;
}
.color-trigger {
--td-button-extra-small-padding-horizontal: 2px;
@@ -482,7 +551,7 @@ onMounted(async () => {
width: 1.5rem;
height: 1.5rem;
display: block;
border-radius: .25rem;
border-radius: .35rem;
}
span {
@@ -492,13 +561,6 @@ onMounted(async () => {
}
}
.inline-options,
.limit-options {
gap: .5rem;
display: flex;
flex-wrap: wrap;
}
.roles {
gap: .75rem;
display: flex;
@@ -555,6 +617,7 @@ onMounted(async () => {
.picker-popup {
background: var(--td-bg-color-container);
border-radius: 1.25rem 1.25rem 0 0;
.picker-header {
display: flex;
+16 -2
View File
@@ -56,6 +56,7 @@ import { Toast } from "tdesign-mobile-vue";
import { Time, useUserStore, type Role } from "timi-web";
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
import GaoUserAPI from "@/api/GaoUserAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { RegistrationEvent, RegistrationLimitType } from "@/types/RegistrationEvent";
import { gaoPermissionCodes } from "@/utils/gaoPermission";
@@ -64,6 +65,7 @@ defineOptions({
});
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const defaultColor = "#2f88ff";
const registrationEventList = ref<RegistrationEvent[]>([]);
@@ -74,6 +76,10 @@ function createRegistrationEvent(): void {
router.push("/management/registration-events/create");
}
async function goSort(): Promise<void> {
await router.push("/management/registration-events/sort");
}
function editRegistrationEvent(item: RegistrationEvent): void {
if (!item.id) {
return;
@@ -111,9 +117,9 @@ function getLimitText(item: RegistrationEvent): string {
const limitTextMap: Record<RegistrationLimitType, string> = {
NONE: "不限次数",
DAILY_HALF_DAY: "上午/下午各一次",
NATURAL_DAY: "每天一次",
INTERVAL_HOUR: `每隔 ${item.limitValue || 0} 小时`,
NATURAL_MONTH: "每月一次",
NATURAL_DAY: "每个自然日一次",
NATURAL_MONTH: "每个自然月一次",
TOTAL_LIMIT: `事件总上限 ${item.limitValue || 0} 次`,
CUSTOMER_TOTAL_LIMIT: `单客户总上限 ${item.limitValue || 0} 次`
};
@@ -156,6 +162,10 @@ async function loadData(): Promise<void> {
}
onMounted(async () => {
navBarStore.clearRight();
if (canUpdateRegistrationEvent.value) {
navBarStore.setRightText("排序", "var(--td-brand-color)", goSort);
}
try {
await loadData();
} catch {
@@ -166,6 +176,10 @@ onMounted(async () => {
});
}
});
onUnmounted(() => {
navBarStore.clearRight();
});
</script>
<style scoped lang="less">
@@ -0,0 +1,353 @@
<template>
<div v-can="gaoPermissionCodes.registrationEvent.update" class="registration-event-sort">
<div class="tip" v-text="'按住右侧拖动手柄调整事件顺序,保存后生效'" />
<div ref="sortListRef" class="items">
<t-cell-group v-if="registrationEventList.length" theme="card">
<t-cell
class="cell"
:title="item.name || '未命名事件'"
v-for="(item, index) in registrationEventList"
:key="item.id"
:class="{ dragging: draggingIndex === index }"
>
<template #leftIcon>
<div class="color" :style="{ background: item.color || defaultColor }" />
</template>
<template #note>
<t-tag
:theme="getStatusTheme(item.status)"
variant="outline"
:content="getStatusText(item.status)"
/>
</template>
<template #rightIcon>
<t-icon
class="handle"
role="button"
name="move"
aria-label="拖动排序"
@pointerdown.stop="startDrag($event, index)"
@pointermove.stop="moveDrag"
@pointerup.stop="finishDrag"
@pointercancel.stop="finishDrag"
@lostpointercapture.stop="finishDrag"
@touchstart.stop="startTouchDrag($event, index)"
@touchmove.stop="moveTouchDrag"
@touchend.stop="finishTouchDrag"
@touchcancel.stop="finishTouchDrag"
/>
</template>
</t-cell>
</t-cell-group>
<div v-else class="empty-block" v-text="loading ? '正在加载登记事件' : '暂无登记事件'" />
</div>
<t-button
class="save"
theme="primary"
:loading="saving"
:disabled="!sortDirty || saving || loading"
@click="saveSort"
>
保存排序
</t-button>
</div>
</template>
<script setup lang="ts">
import { Toast, type TagProps } from "tdesign-mobile-vue";
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { RegistrationEvent, RegistrationEventStatus } from "@/types/RegistrationEvent";
import { gaoPermissionCodes } from "@/utils/gaoPermission";
defineOptions({
name: "RegistrationEventSort"
});
const navBarStore = useNavBarStore();
const defaultColor = "#2f88ff";
const registrationEventList = ref<RegistrationEvent[]>([]);
const sortListRef = ref<HTMLElement>();
const loading = ref(false);
const saving = ref(false);
const sortDirty = ref(false);
const draggingIndex = ref(-1);
let pointerId: number | undefined;
let pointerStartY = 0;
let pointerMoved = false;
let pointerTarget: HTMLElement | undefined;
let dragInputType: "pointer" | "touch" | undefined;
function getStatusText(status?: RegistrationEventStatus): string {
if (status === "DRAFT") {
return "草稿";
}
if (status === "INACTIVE") {
return "不可登记";
}
return "可登记";
}
function getStatusTheme(status?: RegistrationEventStatus): TagProps["theme"] {
if (status === "DRAFT") {
return "warning";
}
if (status === "INACTIVE") {
return "default";
}
return "primary";
}
function resolveDropIndex(clientY: number): number {
const elements = sortListRef.value?.querySelectorAll<HTMLElement>(".item");
if (!elements?.length) {
return -1;
}
for (let index = 0; index < elements.length; index++) {
const rect = elements[index].getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
return index;
}
}
return elements.length - 1;
}
function beginDrag(index: number, clientY: number, target: EventTarget | null): void {
pointerStartY = clientY;
pointerMoved = false;
draggingIndex.value = index;
pointerTarget = target instanceof HTMLElement ? target : undefined;
}
function moveDragAt(clientY: number): void {
if (0 > draggingIndex.value) {
return;
}
if (!pointerMoved && 8 > Math.abs(clientY - pointerStartY)) {
return;
}
pointerMoved = true;
const targetIndex = resolveDropIndex(clientY);
if (0 > targetIndex || targetIndex === draggingIndex.value) {
return;
}
const [item] = registrationEventList.value.splice(draggingIndex.value, 1);
registrationEventList.value.splice(targetIndex, 0, item);
draggingIndex.value = targetIndex;
sortDirty.value = true;
}
function finishDragState(): void {
if (pointerMoved) {
sortDirty.value = true;
}
pointerId = undefined;
pointerTarget = undefined;
dragInputType = undefined;
draggingIndex.value = -1;
}
function startDrag(event: PointerEvent, index: number): void {
if (saving.value || loading.value || dragInputType || !event.isPrimary || event.button !== 0) {
return;
}
event.preventDefault();
pointerId = event.pointerId;
dragInputType = "pointer";
beginDrag(index, event.clientY, event.currentTarget);
pointerTarget?.setPointerCapture(event.pointerId);
}
function moveDrag(event: PointerEvent): void {
if (dragInputType !== "pointer" || pointerId !== event.pointerId || 0 > draggingIndex.value) {
return;
}
event.preventDefault();
moveDragAt(event.clientY);
}
function finishDrag(event: PointerEvent): void {
if (dragInputType !== "pointer" || pointerId !== event.pointerId) {
return;
}
if (pointerTarget?.hasPointerCapture(event.pointerId)) {
pointerTarget.releasePointerCapture(event.pointerId);
}
finishDragState();
}
function startTouchDrag(event: TouchEvent, index: number): void {
if (saving.value || loading.value || dragInputType || event.touches.length !== 1) {
return;
}
event.preventDefault();
const touch = event.touches[0];
pointerId = touch.identifier;
dragInputType = "touch";
beginDrag(index, touch.clientY, event.currentTarget);
}
function moveTouchDrag(event: TouchEvent): void {
if (dragInputType !== "touch" || pointerId === undefined || 0 > draggingIndex.value || event.touches.length !== 1) {
return;
}
event.preventDefault();
moveDragAt(event.touches[0].clientY);
}
function finishTouchDrag(event: TouchEvent): void {
if (dragInputType !== "touch" || pointerId === undefined) {
return;
}
const touch = event.changedTouches[0];
if (touch && touch.identifier !== pointerId) {
return;
}
finishDragState();
}
async function saveSort(): Promise<void> {
if (!sortDirty.value || saving.value) {
return;
}
saving.value = true;
try {
const idList = registrationEventList.value
.map(item => item.id)
.filter((id): id is string => Boolean(id));
await RegistrationEventAPI.sort(idList);
registrationEventList.value.forEach((item, index) => {
item.sort = index;
});
sortDirty.value = false;
showToast("登记事件排序已保存");
} catch {
showToast("登记事件排序保存失败", "error");
} finally {
saving.value = false;
}
}
function showToast(message: string, theme: "success" | "error" = "success"): void {
Toast({
theme,
direction: "row",
message
});
}
async function loadData(): Promise<void> {
loading.value = true;
try {
const result = await RegistrationEventAPI.list({
index: 0,
size: 100
});
registrationEventList.value = result.list || [];
sortDirty.value = false;
} finally {
loading.value = false;
}
}
onMounted(async () => {
navBarStore.clearRight();
try {
await loadData();
} catch {
showToast("登记事件加载失败", "error");
}
});
onUnmounted(() => {
navBarStore.clearRight();
});
</script>
<style scoped lang="less">
.registration-event-sort {
padding: calc(var(--app-nav-height) + 1rem) 0 6rem;
.tip {
color: #52607a;
padding: 0 1rem .75rem;
font-size: .8125rem;
}
.items {
.cell {
position: relative;
&.dragging {
z-index: 1;
box-shadow: 0 .5rem 1rem rgba(15, 23, 42, .16);
}
.color {
flex: 0 0 auto;
width: 1.5rem;
height: 1.5rem;
border-radius: .4rem;
}
.name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.handle {
color: #52607a;
cursor: grab;
}
.cell.dragging,
.cell.dragging * {
touch-action: none;
}
}
}
.empty-block {
color: #52607a;
padding: 2rem 1rem;
text-align: center;
}
.save {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
}
}
:global(html.theme-dark) {
.registration-event-sort {
.tip,
.empty-block,
.handle {
color: #94a3b8;
}
.item {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .5rem 1rem rgba(2, 6, 23, .28);
}
}
}
</style>
+15
View File
@@ -1,6 +1,7 @@
import type { RouteRecordRaw } from "vue-router";
import RegistrationEventEdit from "@/pages/management/RegistrationEventEdit.vue";
import RegistrationEventList from "@/pages/management/RegistrationEventList.vue";
import RegistrationEventSort from "@/pages/management/RegistrationEventSort.vue";
import RegistrationEventRecordDetail from "@/pages/management/RegistrationEventRecordDetail.vue";
import RegistrationEventRecordEdit from "@/pages/management/RegistrationEventRecordEdit.vue";
import RegistrationEventRecordList from "@/pages/management/RegistrationEventRecordList.vue";
@@ -20,6 +21,20 @@ const registrationRoutes: RouteRecordRaw[] = [
darkBodyBackground: "#10161f"
}
},
{
path: "/management/registration-events/sort",
name: "RegistrationEventSort",
component: RegistrationEventSort,
meta: {
depth: 4,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "登记事件排序",
tabBarVisible: false,
bodyBackground: "#F5F5F8",
darkBodyBackground: "#10161f"
}
},
{
path: "/management/registration-events/create",
name: "RegistrationEventCreate",