Files
gao-customer-h5/src/pages/management/EventEdit.vue
T
2026-08-07 18:20:29 +08:00

797 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 v-if="isGlobalManager" label="门店" :arrow="isCreateMode" @click="openStorePicker">
<div class="picker-text" v-text="selectedStoreName" />
</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="isRemoving"
:disabled="isSaving || isRemoving"
@click="confirmRemoveEventItem"
>
删除事件
</t-button>
<t-button
v-can="savePermissionCode"
class="save"
theme="primary"
:loading="isSaving"
:disabled="isRemoving"
@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="storePickerVisible" placement="bottom">
<t-picker
v-model="storePickerDraft"
:columns="storePickerColumns"
title="选择门店"
@cancel="storePickerVisible = false"
@confirm="confirmStorePicker"
/>
</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 { useUserStore, type Role } from "timi-web";
import EventAPI from "@/api/EventAPI";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { EventItem, EventStatus, EventLimitType } from "@/types/EventItem";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type PickerValue = string | number;
type LimitOption = {
label: string;
value: EventLimitType;
};
defineOptions({
name: "EventEdit"
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const routeId = computed(() => String(route.params.id || ""));
const isCreateMode = computed(() => !routeId.value);
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
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 isSaving = ref(false);
const isRemoving = ref(false);
const formModel = reactive({
id: "",
storeId: "",
name: "",
description: "",
color: "#0f766e",
status: "ACTIVE" as EventStatus,
requirePhoto: false,
limitType: "NONE" as EventLimitType,
limitValueText: "",
beginAtText: "",
endAtText: "",
roleCodeList: [] as string[]
});
// ---------- 门店选择 ----------
const storePickerVisible = ref(false);
const storePickerDraft = ref<PickerValue[]>([]);
const storePickerColumns = computed(() => [
currentStoreStore.storeList
.map(item => ({
label: item.name || item.code || "未命名门店",
value: item.id || ""
}))
.filter(item => item.value)
]);
const selectedStoreName = computed(() => {
const store = currentStoreStore.storeList.find(item => item.id === formModel.storeId);
return store?.name || store?.code || "请选择门店";
});
function openStorePicker(): void {
if (!isCreateMode.value) {
return;
}
if (!currentStoreStore.storeList.length) {
Toast.warning("暂无可选门店");
return;
}
storePickerDraft.value = [formModel.storeId];
storePickerVisible.value = true;
}
function confirmStorePicker(): void {
const storeId = String(storePickerDraft.value[0] || "");
if (!storeId) {
return;
}
formModel.storeId = storeId;
storePickerVisible.value = false;
}
// ---------- 颜色 ----------
const colorPickerVisible = ref(false);
const colorPickerRenderKey = ref(0);
const colorDraft = ref("#2f88ff");
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 limitPickerColumns = [limitOptions];
const limitPickerVisible = ref(false);
const limitPickerDraft = ref<string[]>([formModel.limitType]);
// ---------- 状态 ----------
const statusOptions: Array<{ label: string; value: EventStatus }> = [
{ label: "草稿", value: "DRAFT" },
{ label: "可登记", value: "ACTIVE" },
{ label: "不可登记", value: "INACTIVE" }
];
const statusPickerColumns = [statusOptions];
const statusPickerVisible = ref(false);
const statusPickerDraft = ref<EventStatus[]>([formModel.status]);
// ---------- 生效时间 ----------
const beginAtPickerVisible = ref(false);
const endAtPickerVisible = ref(false);
const beginAtDraftText = ref("");
const endAtDraftText = ref("");
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.storeId) {
Toast.warning("请选择门店");
return false;
}
if (!formModel.name.trim()) {
Toast.warning("请输入登记事件名称");
return false;
}
if (!formModel.roleCodeList.length) {
Toast.warning("请至少选择一个可登记角色");
return false;
}
if (isValueLimitType(formModel.limitType) && !resolveLimitValue()) {
Toast.warning("请输入有效的约束数值");
return false;
}
const beginAt = resolveTimestamp(formModel.beginAtText);
const endAt = resolveTimestamp(formModel.endAtText);
if (beginAt !== undefined && endAt !== undefined && endAt < beginAt) {
Toast.warning("终止时间不能早于起始时间");
return false;
}
return true;
}
// ---------- 生效时间 ----------
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;
}
isSaving.value = true;
try {
const payload: EventItem = {
id: formModel.id || undefined,
storeId: formModel.storeId,
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]
};
if (isCreateMode.value) {
await EventAPI.create(payload);
} else {
await EventAPI.update(payload);
}
Toast.success("登记事件已保存");
await router.replace("/management/registration-events");
} catch {
Toast.error("登记事件保存失败");
} finally {
isSaving.value = false;
}
}
// ---------- 删除 ----------
async function confirmRemoveEventItem(): Promise<void> {
const id = formModel.id || routeId.value;
if (!id || isRemoving.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 (isRemoving.value) {
return;
}
isRemoving.value = true;
try {
await EventAPI.remove(id);
Toast.success("登记事件已删除");
await router.replace("/management/registration-events");
} catch {
Toast.error("登记事件删除失败");
} finally {
isRemoving.value = false;
}
}
// ---------- 页面数据 ----------
async function prepareStoreScope(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
const queryStoreId = String(route.query.storeId || "");
formModel.storeId = currentStoreStore.storeList.some(item => item.id === queryStoreId)
? queryStoreId
: currentStoreStore.currentStoreId;
return;
}
const store = await currentStoreStore.loadLoginStore();
formModel.storeId = store?.id || "";
}
async function fillForm(): Promise<void> {
await prepareStoreScope();
roleOptions.value = await ManagedUserAPI.roleList();
if (isCreateMode.value) {
return;
}
const current = await EventAPI.detail(routeId.value);
Object.assign(formModel, {
id: current.id || "",
storeId: current.storeId || formModel.storeId,
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.toDateTime(current.beginAt) : "",
endAtText: current.endAt ? Time.toDateTime(current.endAt) : "",
roleCodeList: [...(current.roleCodeList || [])]
});
colorDraft.value = formModel.color;
}
onMounted(async () => {
try {
await fillForm();
} catch {
Toast.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>