refactor event

This commit is contained in:
Timi
2026-08-05 22:50:22 +08:00
parent 22ff13b1fe
commit 1243068fc3
46 changed files with 961 additions and 789 deletions
+699
View File
@@ -0,0 +1,699 @@
<template>
<div v-can="pageAccess" class="registration-event-edit">
<t-form class="form" label-align="left">
<t-form-item label="颜色">
<t-button
size="extra-small"
variant="outline"
class="color-trigger"
@click="openColorPicker"
>
<div class="content">
<span class="swatch" :style="{ background: formModel.color }" />
<span v-text="formModel.color.toLowerCase()" />
</div>
</t-button>
</t-form-item>
<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
align="right"
/>
</t-form-item>
<t-form-item label="需要拍照" content-align="right">
<t-switch v-model="formModel.requirePhoto" />
</t-form-item>
<t-form-item label="状态" arrow @click="openStatusPicker">
<div class="picker-text" v-text="getStatusText(formModel.status)" />
</t-form-item>
</t-form>
<section class="roles">
<div class="title">
<span>可登记角色</span>
<span class="hint">必须至少选择一个角色,上级角色拥有子级角色可登记事件的登记权限</span>
</div>
<t-checkbox-group v-model="formModel.roleCodeList" class="group">
<t-checkbox
v-for="item in roleOptions"
:key="item.code"
:value="item.code"
class="item"
borderless
>
<span v-text="item.name || item.code" />
</t-checkbox>
</t-checkbox-group>
</section>
<div class="bottom-actions">
<t-button
v-if="!isCreateMode"
v-can="deletePermissionCode"
class="delete bg-white"
theme="danger"
variant="outline"
:loading="removing"
:disabled="saving || removing"
@click="confirmRemoveEventItem"
>
删除事件
</t-button>
<t-button
v-can="savePermissionCode"
class="save"
theme="primary"
:loading="saving"
:disabled="removing"
@click="saveEventItem"
>
保存
</t-button>
</div>
</div>
<t-popup v-model="colorPickerVisible" placement="bottom" @opened="handleColorPickerOpened">
<div class="color-popup">
<div class="header">
<t-button variant="text" size="small" @click="cancelColorPicker">取消</t-button>
<span class="title" v-text="'颜色选择'" />
<t-button theme="primary" variant="text" size="small" @click="confirmColorPicker">确定</t-button>
</div>
<t-color-picker
v-if="colorPickerVisible"
:key="colorPickerRenderKey"
v-model="colorDraft"
format="HEX8"
type="multiple"
:swatch-colors="colorSwatchList"
@palette-bar-change="updateColorDraftFromPalette"
/>
</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">
<t-date-time-picker
v-if="beginAtPickerVisible"
v-model="beginAtDraftText"
:start="minimumDateTimeText"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="起始时间"
@cancel="beginAtPickerVisible = false"
@confirm="confirmBeginAt"
/>
</div>
</t-popup>
<t-popup v-model="endAtPickerVisible" placement="bottom">
<div class="picker-popup">
<t-date-time-picker
v-if="endAtPickerVisible"
v-model="endAtDraftText"
:start="endAtMinimumDateTimeText"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="终止时间"
@cancel="endAtPickerVisible = false"
@confirm="confirmEndAt"
/>
</div>
</t-popup>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
import type { ColorObject } from "tdesign-mobile-vue";
import type { Role } from "timi-web";
import EventAPI from "@/api/EventAPI";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import type { EventItem, EventStatus, EventLimitType } from "@/types/EventItem";
import { permissionCodes } from "@/utils/permission";
type LimitOption = {
label: string;
value: EventLimitType;
};
defineOptions({
name: "EventEdit"
});
const route = useRoute();
const router = useRouter();
const routeId = computed(() => String(route.params.id || ""));
const isCreateMode = computed(() => !routeId.value);
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.event.create : permissionCodes.event.update);
const deletePermissionCode = permissionCodes.event.delete;
const pageAccess = computed(() => ({
value: [permissionCodes.event.read, permissionCodes.user.read, savePermissionCode.value],
mode: "and" as const
}));
const roleOptions = ref<Role[]>([]);
const colorPickerVisible = ref(false);
const colorPickerRenderKey = ref(0);
const colorDraft = ref("#2f88ff");
const beginAtPickerVisible = ref(false);
const endAtPickerVisible = ref(false);
const beginAtDraftText = ref("");
const endAtDraftText = ref("");
const saving = ref(false);
const removing = ref(false);
const colorSwatchList = [
"#2F88FF",
"#0EA5E9",
"#10B981",
"#22C55E",
"#F59E0B",
"#F97316",
"#EF4444",
"#EC4899",
"#8B5CF6",
"#64748B"
];
const limitOptions: LimitOption[] = [
{ label: "不限", value: "NONE" },
{ 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: EventStatus }> = [
{ label: "草稿", value: "DRAFT" },
{ label: "可登记", value: "ACTIVE" },
{ label: "不可登记", value: "INACTIVE" }
];
const limitPickerColumns = [limitOptions];
const statusPickerColumns = [statusOptions];
const formModel = reactive({
id: "",
name: "",
description: "",
color: "#0f766e",
status: "ACTIVE" as EventStatus,
requirePhoto: false,
limitType: "NONE" as EventLimitType,
limitValueText: "",
beginAtText: "",
endAtText: "",
roleCodeList: [] as string[]
});
const limitPickerVisible = ref(false);
const limitPickerDraft = ref<string[]>([formModel.limitType]);
const statusPickerVisible = ref(false);
const statusPickerDraft = ref<EventStatus[]>([formModel.status]);
const minimumDateTimeText = "1970-01-01 00:00";
const nowDateTimeText = computed(() => Time.formatDateTime(new Date()));
const endAtMinimumDateTimeText = computed(() => formModel.beginAtText || minimumDateTimeText);
const defaultEndAtText = computed(() => {
const date = new Date();
date.setDate(date.getDate() + 7);
return Time.formatDateTime(date);
});
const limitValuePlaceholder = computed(() => {
if (formModel.limitType === "INTERVAL_HOUR") {
return "请输入间隔小时数";
}
if (formModel.limitType === "CUSTOMER_TOTAL_LIMIT") {
return "请输入单客户总次数上限";
}
return "请输入总次数上限";
});
function openColorPicker(): void {
colorDraft.value = formModel.color;
colorPickerVisible.value = true;
}
function handleColorPickerOpened(): void {
colorPickerRenderKey.value += 1;
}
function cancelColorPicker(): void {
colorPickerVisible.value = false;
colorDraft.value = formModel.color;
}
function confirmColorPicker(): void {
formModel.color = colorDraft.value;
colorPickerVisible.value = false;
}
function updateColorDraftFromPalette(context: { color: ColorObject }): void {
colorDraft.value = context.color.hex8;
}
function getLimitText(value: EventLimitType): string {
return limitOptions.find(option => option.value === value)?.label || "不限";
}
function getStatusText(value: EventStatus): 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 EventLimitType;
}
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: EventLimitType): boolean {
return value === "INTERVAL_HOUR" || value === "TOTAL_LIMIT" || value === "CUSTOMER_TOTAL_LIMIT";
}
function resolveLimitValue(): number | undefined {
if (!isValueLimitType(formModel.limitType)) {
return undefined;
}
const value = Number(formModel.limitValueText);
return 0 < value ? Math.floor(value) : undefined;
}
function resolveTimestamp(valueText: string): number | undefined {
if (!valueText.trim()) {
return undefined;
}
const date = new Date(valueText);
const timestamp = Time.toTimestamp(date);
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function validateForm(): boolean {
if (!formModel.name.trim()) {
showToast("请输入登记事件名称", "warning");
return false;
}
if (!formModel.roleCodeList.length) {
showToast("请至少选择一个可登记角色", "warning");
return false;
}
if (isValueLimitType(formModel.limitType) && !resolveLimitValue()) {
showToast("请输入有效的约束数值", "warning");
return false;
}
const beginAt = resolveTimestamp(formModel.beginAtText);
const endAt = resolveTimestamp(formModel.endAtText);
if (beginAt !== undefined && endAt !== undefined && endAt < beginAt) {
showToast("终止时间不能早于起始时间", "warning");
return false;
}
return true;
}
function buildPayload(): EventItem {
return {
id: formModel.id || undefined,
name: formModel.name.trim(),
description: formModel.description,
color: formModel.color,
status: formModel.status,
requirePhoto: formModel.requirePhoto,
limitType: formModel.limitType,
limitValue: resolveLimitValue(),
beginAt: resolveTimestamp(formModel.beginAtText),
endAt: resolveTimestamp(formModel.endAtText),
roleCodeList: [...formModel.roleCodeList]
};
}
function openBeginAtPicker(): void {
beginAtDraftText.value = formModel.beginAtText || nowDateTimeText.value;
beginAtPickerVisible.value = true;
}
function openEndAtPicker(): void {
endAtDraftText.value = formModel.endAtText || defaultEndAtText.value;
endAtPickerVisible.value = true;
}
function confirmBeginAt(value: string | number): void {
formModel.beginAtText = String(value);
beginAtPickerVisible.value = false;
}
function confirmEndAt(value: string | number): void {
formModel.endAtText = String(value);
endAtPickerVisible.value = false;
}
function clearBeginAt(): void {
formModel.beginAtText = "";
beginAtDraftText.value = "";
beginAtPickerVisible.value = false;
}
function clearEndAt(): void {
formModel.endAtText = "";
endAtDraftText.value = "";
endAtPickerVisible.value = false;
}
async function saveEventItem(): Promise<void> {
if (!validateForm()) {
return;
}
saving.value = true;
try {
const payload = buildPayload();
if (isCreateMode.value) {
await EventAPI.create(payload);
} else {
await EventAPI.update(payload);
}
showToast("登记事件已保存");
await router.replace("/management/registration-events");
} catch {
showToast("登记事件保存失败", "error");
} finally {
saving.value = false;
}
}
async function confirmRemoveEventItem(): Promise<void> {
const id = formModel.id || routeId.value;
if (!id || removing.value) {
return;
}
const eventName = formModel.name.trim() || "未命名事件";
const dialog = DialogPlugin.confirm({
title: "删除登记事件",
content: `确定删除登记事件 ${eventName} 吗?删除后无法恢复。`,
cancelBtn: "取消",
confirmBtn: "删除",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await removeEventItem(id);
}
});
}
async function removeEventItem(id: string): Promise<void> {
if (removing.value) {
return;
}
removing.value = true;
try {
await EventAPI.remove(id);
showToast("登记事件已删除");
await router.replace("/management/registration-events");
} catch {
showToast("登记事件删除失败", "error");
} finally {
removing.value = false;
}
}
function showToast(message: string, theme: "success" | "error" | "warning" = "success"): void {
Toast({
theme,
direction: "row",
message
});
}
async function fillForm(): Promise<void> {
roleOptions.value = await ManagedUserAPI.roleList();
if (isCreateMode.value) {
return;
}
const current = await EventAPI.detail(routeId.value);
Object.assign(formModel, {
id: current.id || "",
name: current.name || "",
description: current.description || "",
color: current.color || "#2f88ff",
status: current.status === "DRAFT" ? "DRAFT" : current.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
requirePhoto: current.requirePhoto ?? false,
limitType: current.limitType || "NONE",
limitValueText: current.limitValue ? String(current.limitValue) : "",
beginAtText: current.beginAt ? Time.formatDateTime(new Date(current.beginAt)) : "",
endAtText: current.endAt ? Time.formatDateTime(new Date(current.endAt)) : "",
roleCodeList: [...(current.roleCodeList || [])]
});
colorDraft.value = formModel.color;
}
onMounted(async () => {
try {
await fillForm();
} catch {
showToast("登记事件加载失败", "error");
}
});
</script>
<style scoped lang="less">
.registration-event-edit {
padding: calc(var(--app-nav-height) + 1rem) 0 5rem 0;
.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 {
color: #52607a;
width: 100%;
text-align: right;
}
.color-trigger {
--td-button-extra-small-padding-horizontal: 2px;
gap: .75rem;
display: flex;
align-items: center;
padding-right: .875rem;
justify-content: flex-start;
.content {
gap: .5rem;
display: flex;
.swatch {
width: 1.5rem;
height: 1.5rem;
display: block;
border-radius: .35rem;
}
span {
color: #52607a;
font-size: .875rem;
}
}
}
.roles {
gap: .75rem;
display: flex;
padding: 1rem;
flex-direction: column;
.title {
gap: .25rem;
display: flex;
flex-direction: column;
span:first-child {
font-weight: 700;
}
.hint {
color: #52607a;
font-size: .8125rem;
}
}
.group {
gap: .5rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
.item {
margin: 0;
padding: .875rem;
background: #fff;
border: 1px solid rgba(148, 163, 184, .22);
border-radius: .75rem;
}
}
}
.bottom-actions {
gap: .75rem;
left: 1rem;
right: 1rem;
display: flex;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
.delete {
flex: 1;
}
.save {
flex: 2;
}
}
}
.picker-popup {
background: var(--td-bg-color-container);
border-radius: 1.25rem 1.25rem 0 0;
.picker-header {
display: flex;
padding: .75rem 1rem 0;
align-items: center;
justify-content: space-between;
}
}
.color-popup {
padding: .75rem 0 1.25rem;
background: #fff;
border-radius: 1rem 1rem 0 0;
.header {
display: flex;
padding: 0 1rem;
align-items: center;
justify-content: space-between;
span {
font-size: 1rem;
font-weight: 700;
}
}
}
:global(html.theme-dark) {
.registration-event-edit {
.color-trigger span,
.roles .title .hint {
color: #94a3b8;
}
.color-trigger .swatch {
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, .24);
}
.color-popup {
background: #0f172a;
}
.roles .group .item {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
}
}
</style>