refactor file tree

This commit is contained in:
Timi
2026-08-11 15:24:36 +08:00
parent 575d41b2e2
commit 819c1623eb
42 changed files with 86 additions and 86 deletions
+796
View File
@@ -0,0 +1,796 @@
<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>
+378
View File
@@ -0,0 +1,378 @@
<template>
<div
v-can="{ value: [permissionCodes.event.read, permissionCodes.user.read], mode: 'and' }"
:class="{ 'has-filter': hasFilter }"
class="registration-event-list"
>
<div v-if="hasFilter" class="filter-menu">
<t-dropdown-menu>
<t-dropdown-item
v-if="isGlobalManager"
v-model="selectedStoreId"
:label="storeFilterLabel"
:options="storeOptions"
@change="selectStoreFilter"
/>
</t-dropdown-menu>
</div>
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="fetch"
>
<t-cell-group theme="card">
<t-cell
class="item"
v-for="item in visibleEventList"
:key="item.id"
:arrow="canUpdateEventItem"
:hover="canUpdateEventItem"
@click="toEventItem(item)"
>
<template #title>
<div class="header">
<div class="color" :style="{ background: item.color || defaultColor }" />
<div class="title" v-text="item.name || '未命名事件'" />
<t-tag
class="status"
:theme="getStatusTag(item).theme"
variant="outline"
:content="getStatusTag(item).text"
/>
<t-tag v-if="item.requirePhoto" theme="warning" variant="outline">需拍照</t-tag>
</div>
</template>
<template #description>
<div class="description">
<div class="line">
<span>可登记角色</span>
<t-tag
v-for="roleCode in item.roleCodeList"
:key="roleCode"
theme="primary"
variant="light-outline"
:content="getRoleName(roleCode)"
/>
<span v-if="!(item.roleCodeList || []).length" class="muted" v-text="'未配置'" />
</div>
<div class="line" v-text="getLimitText(item)" />
</div>
</template>
</t-cell>
</t-cell-group>
<div v-if="!visibleEventList.length" class="empty-block" v-text="emptyText" />
<div v-if="visibleEventList.length" class="load-state" v-text="isNoMore ? '已经到底了' : '上拉加载更多'" />
</t-pull-down-refresh>
<t-button v-can="permissionCodes.event.create" class="create" theme="primary" @click="toCreate">
<template #icon>
<t-icon name="add" />
</template>
新增登记事件
</t-button>
</div>
</template>
<script setup lang="ts">
import { Toast, type DropdownValue } from "tdesign-mobile-vue";
import { type Page, type PageResult, type Role, useUserStore } from "timi-web";
import EventAPI from "@/api/EventAPI";
import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventItem, EventLimitType, EventStatus } from "@/types/EventItem";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
name: "eventList"
});
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const hasFilter = computed(() => isGlobalManager.value);
// ---------- 门店筛选 ----------
const allStoreFilterValue = "__ALL_STORES__";
const selectedStoreId = ref(allStoreFilterValue);
const loginStoreId = ref("");
const storeOptions = computed(() => [
{ label: "全部门店", value: allStoreFilterValue },
...currentStoreStore.storeList
.map(item => ({
label: item.name || item.code || "未命名门店",
value: item.id || ""
}))
.filter(item => item.value)
]);
const storeFilterLabel = computed(() => storeOptions.value.find(item => item.value === selectedStoreId.value)?.label || "全部门店");
const eventListOptions = computed(() => {
if (isGlobalManager.value) {
return selectedStoreId.value === allStoreFilterValue
? { allStores: true }
: { storeId: selectedStoreId.value };
}
return { storeId: loginStoreId.value };
});
// ---------- 列表数据 ----------
const defaultColor = "#2f88ff";
const roleOptions = ref<Role[]>([]);
const isRefreshing = ref(false);
const isLoading = ref(false);
const isNoMore = ref(false);
const page = reactive<Page<EventItem>>({
index: 0,
size: 15
});
const pageResult = reactive<PageResult<EventItem>>({
total: 0,
list: []
});
const canUpdateEventItem = computed(() => userStore.hasPermissions(permissionCodes.event.update));
const visibleEventList = computed(() => pageResult.list);
const emptyText = computed(() => {
if (isLoading.value) {
return "正在加载登记事件";
}
return "暂无登记事件";
});
type StatusTag = {
text: string;
theme: "default" | "primary" | "danger";
};
const fixedStatusTagMap: Partial<Record<EventStatus, StatusTag>> = {
DRAFT: { text: "草稿", theme: "default" },
INACTIVE: { text: "不可登记", theme: "default" },
DELETED: { text: "已删除", theme: "default" }
};
const activeStatusTag: StatusTag = { text: "可登记", theme: "primary" };
const notEffectiveStatusTag: StatusTag = { text: "未生效", theme: "default" };
const expiredStatusTag: StatusTag = { text: "已过期", theme: "danger" };
function toEventItem(item: EventItem): void {
if (!canUpdateEventItem.value) {
return;
}
if (!item.id) {
return;
}
router.push(`/management/registration-events/${item.id}/edit`);
}
// ---------- 展示文案 ----------
function getStatusTag(item: EventItem): StatusTag {
const status = item.status || "ACTIVE";
const fixedStatusTag = fixedStatusTagMap[status];
if (fixedStatusTag) {
return fixedStatusTag;
}
const now = Date.now();
if (item.beginAt !== undefined && now < item.beginAt) {
return notEffectiveStatusTag;
}
if (item.endAt !== undefined && item.endAt < now) {
return expiredStatusTag;
}
return activeStatusTag;
}
function getLimitText(item: EventItem): string {
const limitTextMap: Record<EventLimitType, string> = {
NONE: "不限次数",
DAILY_HALF_DAY: "上午/下午各一次",
INTERVAL_HOUR: `每隔 ${item.limitValue || 0} 小时`,
NATURAL_DAY: "每个自然日一次",
NATURAL_MONTH: "每个自然月一次",
TOTAL_LIMIT: `事件总上限 ${item.limitValue || 0} 次`,
CUSTOMER_TOTAL_LIMIT: `单客户总上限 ${item.limitValue || 0} 次`
};
return limitTextMap[item.limitType || "NONE"];
}
function getRoleName(code: string): string {
const role = roleOptions.value.find(item => item.code === code);
return role?.name || code;
}
// ---------- 页面数据 ----------
async function loadStoreScope(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
return;
}
const store = await currentStoreStore.loadLoginStore();
loginStoreId.value = store?.id || "";
}
async function fetch(): Promise<void> {
if (isLoading.value || isNoMore.value) {
return;
}
isLoading.value = true;
try {
const result = await EventAPI.list(page, eventListOptions.value);
pageResult.total = result.total;
pageResult.list = pageResult.list.concat(result.list || []);
page.index += 1;
isNoMore.value = (result.list || []).length < page.size || pageResult.total <= pageResult.list.length;
} finally {
isLoading.value = false;
}
}
async function refresh(): Promise<void> {
isRefreshing.value = true;
try {
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = false;
await fetch();
} finally {
isRefreshing.value = false;
}
}
async function loadData(): Promise<void> {
await loadStoreScope();
const [eventResult, roles] = await Promise.all([
EventAPI.list(page, eventListOptions.value),
ManagedUserAPI.roleList()
]);
roleOptions.value = roles || [];
pageResult.total = eventResult.total;
pageResult.list = eventResult.list || [];
page.index = 1;
isNoMore.value = pageResult.list.length >= pageResult.total || pageResult.list.length < page.size;
}
async function selectStoreFilter(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
selectedStoreId.value = String(value);
await refresh();
}
async function toCreate(): Promise<void> {
await router.push({
path: "/management/registration-events/create",
query: isGlobalManager.value && selectedStoreId.value !== allStoreFilterValue
? { storeId: selectedStoreId.value }
: undefined
});
}
onMounted(async () => {
navBarStore.clearRight();
if (canUpdateEventItem.value) {
navBarStore.setRightText("排序", "var(--td-brand-color)", () => {
router.push("/management/registration-events/sort");
});
}
try {
await loadData();
} catch {
Toast.error("登记事件加载失败");
}
});
onUnmounted(() => {
navBarStore.clearRight();
});
</script>
<style scoped lang="less">
.registration-event-list {
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
&.has-filter {
padding-top: calc(var(--app-nav-height) + 4rem);
}
.filter-menu {
top: var(--app-nav-height);
left: 0;
right: 0;
z-index: 20;
position: fixed;
background: var(--td-bg-color-container);
}
.item {
.header {
gap: .5rem;
width: 100%;
display: flex;
margin-top: .25rem;
.color {
width: 1.5rem;
height: 1.5rem;
border-radius: .4rem;
}
}
.description {
margin-top: .5rem;
.line {
gap: .5rem;
display: flex;
align-items: center;
.muted {
color: #52607a;
}
}
}
}
.empty-block {
color: #52607a;
padding: 2rem 1rem;
text-align: center;
}
.create {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
}
}
:global(html.theme-dark) {
.registration-event-list {
.item {
background: rgba(15, 23, 42, .88);
border-color: rgba(148, 163, 184, .16);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .28);
.content {
p,
.meta span {
color: #94a3b8;
}
}
}
.empty-block {
color: #94a3b8;
}
}
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<template>
<div v-can="permissionCodes.eventRecord.read" class="registration-record-detail">
<customer-profile-card
v-if="record?.customer"
class="customer-profile"
:customer="record.customer"
@click="toCustomerDetail"
/>
<t-cell-group v-if="record" title="登记信息" theme="card">
<t-cell title="登记事件" :note="record.event?.name || '未知事件'" />
<t-cell title="登记时间" :note="record.registeredAt ? Time.toPassedDateTime(record.registeredAt) : '-'" />
<t-cell title="登记人" :note="Text.display(record.operator?.nick || record.operator?.name)" />
<t-cell title="客户编码" :note="Text.display(record.customer?.code)" />
<t-cell title="备注" :note="Text.display(record.remark)" />
</t-cell-group>
<section v-if="record" class="photo-panel">
<photo-scroller
:model-value="recordPhotoList"
:show-add="false"
:show-remove="false"
title="登记照片"
/>
</section>
<t-empty v-else :description="isLoading ? '正在加载登记事件记录' : '登记事件记录不存在'" />
</div>
</template>
<script setup lang="ts">
import { Text, Time, useUserStore } from "timi-web";
import EventRecordAPI from "@/api/EventRecordAPI";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventRecord } from "@/types/EventRecord";
import { getEventRecordPhotoURL } from "@/utils/eventRecord";
import { permissionCodes } from "@/utils/permission";
defineOptions({
name: "EventRecordDetail"
});
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
// ---------- 记录数据 ----------
const record = ref<EventRecord>();
const isLoading = ref(false);
const canUpdateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.update));
const recordPhotoList = computed(() => {
const url = getEventRecordPhotoURL(record.value);
return url ? [url] : [];
});
// ---------- 页面数据 ----------
async function loadRecord(): Promise<void> {
const recordId = String(route.params.id || "");
if (!recordId) {
record.value = undefined;
return;
}
isLoading.value = true;
try {
record.value = await EventRecordAPI.detail(recordId);
} catch (err) {
record.value = undefined;
console.warn("登记事件记录加载失败", err);
} finally {
isLoading.value = false;
}
}
// ---------- 导航 ----------
async function toEdit(): Promise<void> {
const recordId = String(route.params.id || "");
if (!recordId) {
return;
}
await router.push(`/management/registration-event-records/${recordId}/edit`);
}
async function toCustomerDetail(): Promise<void> {
const customerId = record.value?.customer?.id;
if (!customerId) {
return;
}
await router.push(`/management/customers/${customerId}`);
}
watch(() => route.params.id, async () => {
await loadRecord();
}, { immediate: true });
onMounted(() => {
navBarStore.clearRight();
if (canUpdateRecord.value) {
navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit);
}
});
onUnmounted(() => {
navBarStore.clearRight();
});
</script>
<style scoped lang="less">
.registration-record-detail {
gap: 1rem;
min-height: 100vh;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
display: flex;
flex-direction: column;
.customer-profile {
margin: 0 1rem;
}
.photo-panel {
padding: 1rem;
margin: 0 1rem;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
}
}
:global(html.theme-dark) {
.registration-record-detail {
.photo-panel {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
}
}
</style>
+735
View File
@@ -0,0 +1,735 @@
<template>
<div v-can="pageAccess" class="registration-record-edit">
<t-form class="form" label-align="left">
<t-form-item label="客户" required-mark>
<t-input
:model-value="customerDisplayText"
class="customer"
readonly
borderless
placeholder="选择或扫码客户"
@click="openSelectedCustomerList"
>
<template #suffix-icon>
<div class="actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
@click.stop="selectorVisible = true"
>选择</t-button>
<t-button
theme="primary"
size="extra-small"
@click.stop="toggleScanner"
>
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
</t-button>
</div>
</template>
</t-input>
</t-form-item>
<div v-if="scannerVisible" class="code-scanner-wrapper">
<code-scanner
visible
:status-text="scanStatus"
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
</div>
<customer-profile-card
v-if="showSelectedCustomerProfile"
class="customer-profile"
:customer="selectedCustomer"
/>
<event-selector
v-model="selectedEventIdList"
class="registration-event-selector"
:event-list="eventList"
required-mark
/>
<t-form-item label="登记时间" content-align="right">
<div class="time-actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
:content="registeredAtText || '此刻'"
@click="openRegisteredAtPicker"
/>
<t-button
v-if="registeredAtText"
theme="primary"
variant="text"
size="extra-small"
content="此刻"
@click="resetRegisteredAt"
/>
</div>
</t-form-item>
<t-form-item label="登记备注">
<t-textarea
v-model.trim="note"
class="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="登记说明"
/>
</t-form-item>
<div class="photo-part">
<photo-scroller v-model="photoList" :loading="isPhotoUploading" @add="capturePhoto" />
</div>
</t-form>
<t-cell-group
v-if="recentRecordList.length"
title="最近登记事件列表"
class="record-list"
theme="card"
>
<t-cell
v-for="item in recentRecordList"
:key="item.id"
:title="getRecordEventName(item)"
:description="item.registeredAt ? Time.toPassedDateTime(item.registeredAt) : '-'"
arrow
hover
@click="toRecordDetail(item.id || '')"
>
<template #leftIcon>
<t-image v-if="getRecordPhotoURL(item)" :src="getRecordPhotoURL(item)" alt="事件照片" fit="cover" class="record-photo" />
</template>
</t-cell>
</t-cell-group>
<div v-else class="empty">当前客户暂无事件记录</div>
<customer-selector
v-model="selectedCustomerIdList"
v-model:visible="selectorVisible"
:selection-mode="isEditMode ? 'single' : 'multiple'"
@change="handleCustomerChange"
/>
<t-popup v-model="selectedCustomerListVisible" placement="bottom">
<div class="selected-customer-popup bg-white">
<div class="popup-header">
<t-button variant="text" size="small" @click="selectedCustomerListVisible = false">关闭</t-button>
<h3>已选客户</h3>
<t-button theme="primary" variant="text" size="small" @click="openCustomerSelector">选择</t-button>
</div>
<customer-list
class="list"
:customer-list="selectedCustomerList"
:removable="!isEditMode"
empty-text="暂未选择客户"
@remove="removeSelectedCustomer"
/>
</div>
</t-popup>
<input
ref="recordPhotoInputRef"
class="file-input"
type="file"
accept="image/*"
multiple
@change="handleRecordPhotoSelected"
>
<t-button theme="primary" class="save" :disabled="!canSubmit" :loading="isSaving" @click="submitRecord">
<span v-text="saveButtonText" />
</t-button>
</div>
<t-popup v-model="registeredAtPickerVisible" placement="bottom">
<t-date-time-picker
v-if="registeredAtPickerVisible"
v-model="registeredAtDraftText"
:start="minimumDateTimeText"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="登记时间"
@cancel="registeredAtPickerVisible = false"
@confirm="confirmRegisteredAt"
/>
</t-popup>
</template>
<script setup lang="ts">
import { AttachmentAPI, Time, type Attachment } from "timi-web";
import { Toast } from "tdesign-mobile-vue";
import CustomerAPI from "@/api/CustomerAPI";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerList from "@/components/CustomerList.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import EventSelector from "@/components/EventSelector.vue";
import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
import type { EventRecord } from "@/types/EventRecord";
import { getEventRecordPhotoURL } from "@/utils/eventRecord";
import { permissionCodes } from "@/utils/permission";
import { compressUploadImageList } from "@/utils/imageCompression";
defineOptions({
name: "EventRecordEdit"
});
const router = useRouter();
const route = useRoute();
const settingStore = useSettingStore();
type EditablePhoto = {
attachment?: Partial<Attachment>;
tempFileId?: string;
url: string;
};
// ---------- 路由权限 ----------
const routeId = computed(() => String(route.params.id || ""));
const isEditMode = computed(() => !!routeId.value);
const pageAccess = computed(() => isEditMode.value ? permissionCodes.eventRecord.update : permissionCodes.eventRecord.create);
const saveButtonText = computed(() => isEditMode.value ? "保存记录" : "提交");
// ---------- 客户 ----------
const customerCode = ref("");
const selectorVisible = ref(false);
const selectedCustomerListVisible = ref(false);
const selectedCustomerIdList = ref<string[]>([]);
const selectedCustomerList = ref<Customer[]>([]);
const selectedCustomer = computed(() => selectedCustomerList.value[0]);
const showSelectedCustomerProfile = computed(() => selectedCustomerList.value.length === 1 && !!selectedCustomer.value?.id);
const customerDisplayText = computed(() => {
if (1 < selectedCustomerList.value.length) {
return `已选择 ${selectedCustomerList.value.length} 位客户`;
}
return selectedCustomer.value?.name || selectedCustomer.value?.code || customerCode.value;
});
async function handleCustomerChange(value: string | string[]): Promise<void> {
selectedCustomerIdList.value = Array.isArray(value) ? value : [value];
await loadSelectedCustomerList();
await loadRecentRecordList();
}
async function loadSelectedCustomerList(): Promise<void> {
const customerList = await Promise.all(selectedCustomerIdList.value.map(id => CustomerAPI.detail(id)));
selectedCustomerList.value = customerList.filter(item => !!item.id);
const firstCustomer = selectedCustomer.value;
customerCode.value = firstCustomer?.code || "";
scanCode.value = firstCustomer?.code || "";
}
function openSelectedCustomerList(): void {
selectedCustomerListVisible.value = true;
}
async function openCustomerSelector(): Promise<void> {
selectedCustomerListVisible.value = false;
await nextTick();
selectorVisible.value = true;
}
async function removeSelectedCustomer(customer: Customer): Promise<void> {
if (!customer.id || isEditMode.value) {
return;
}
selectedCustomerIdList.value = selectedCustomerIdList.value.filter(id => id !== customer.id);
selectedCustomerList.value = selectedCustomerList.value.filter(item => item.id !== customer.id);
await loadRecentRecordList();
}
async function toRecordDetail(id: string): Promise<void> {
if (!id) {
return;
}
await router.push(`/management/registration-event-records/${id}`);
}
async function loadSelectedCustomer(): Promise<void> {
const code = customerCode.value.trim();
if (!code) {
selectedCustomerList.value = [];
selectedCustomerIdList.value = [];
recentRecordList.value = [];
return;
}
const customer = await CustomerAPI.findByCode(code);
if (!customer) {
selectedCustomerList.value = [];
selectedCustomerIdList.value = [];
recentRecordList.value = [];
scanStatus.value = `客户 ${code} 不存在,请先建档。`;
return;
}
if (customer.id) {
selectedCustomerIdList.value = isEditMode.value
? [customer.id]
: [...new Set([...selectedCustomerIdList.value, customer.id])];
selectedCustomerList.value = isEditMode.value
? [customer]
: [...selectedCustomerList.value.filter(item => item.id !== customer.id), customer];
}
await loadRecentRecordList();
}
// ---------- 最近记录 ----------
const recentRecordList = ref<EventRecord[]>([]);
async function loadRecentRecordList(): Promise<void> {
const customerId = selectedCustomer.value?.id;
if (!customerId) {
recentRecordList.value = [];
return;
}
const endAt = Time.toTimestamp(new Date());
const result = await EventRecordAPI.list({
index: 0,
size: 5,
customerId,
beginAt: 0,
endAt
});
recentRecordList.value = (result.list || [])
.sort((left, right) => (right.registeredAt || 0) - (left.registeredAt || 0))
.slice(0, 5);
}
function getRecordEventName(record: EventRecord): string {
return record.event?.name || (record.eventId ? eventNameMap.value.get(record.eventId) : "") || "未知事件";
}
function getRecordPhotoURL(record: EventRecord): string {
return getEventRecordPhotoURL(record);
}
// ---------- 扫码 ----------
const scanCode = ref(customerCode.value);
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
let latestDecodedText = "";
async function toggleScanner(): Promise<void> {
scannerVisible.value = !scannerVisible.value;
if (scannerVisible.value) {
latestDecodedText = "";
}
}
async function handleDecodedText(rawText: string): Promise<void> {
const normalizedText = rawText.trim();
if (!normalizedText || normalizedText === latestDecodedText) {
return;
}
latestDecodedText = normalizedText;
scanCode.value = normalizedText;
await settingStore.playScanSuccessSound();
await applyScanCode();
}
async function applyScanCode(): Promise<void> {
customerCode.value = scanCode.value.trim();
scanStatus.value = `已识别 ${customerCode.value},正在加载客户档案。`;
await loadSelectedCustomer();
if (!selectedCustomer.value) {
return;
}
scanStatus.value = `已识别 ${selectedCustomer.value.code || customerCode.value},已自动选中 ${selectedCustomer.value.name || "未命名客户"}。`;
scannerVisible.value = false;
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
// ---------- 事件 ----------
const eventList = ref<EventItem[]>([]);
const selectedEventIdList = ref<string[]>([]);
const eventNameMap = computed(() => {
const map = new Map<string, string>();
for (const item of eventList.value) {
if (item.id) {
map.set(item.id, item.name || "未命名事件");
}
}
return map;
});
function appendRecordEvent(record: EventRecord): void {
if (!record.event?.id || eventList.value.some(item => item.id === record.event?.id)) {
return;
}
eventList.value = [record.event, ...eventList.value];
}
// ---------- 照片 ----------
const recordPhotoInputRef = ref<HTMLInputElement>();
const photoItemList = ref<EditablePhoto[]>([]);
const isPhotoUploading = ref(false);
const photoList = computed<string[]>({
get() {
return photoItemList.value.map(item => item.url);
},
set(value) {
const urlSet = new Set(value);
photoItemList.value = photoItemList.value.filter(item => urlSet.has(item.url));
}
});
function capturePhoto(): void {
recordPhotoInputRef.value?.click();
}
async function handleRecordPhotoSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const fileList = Array.from(input.files || []);
if (!fileList.length) {
return;
}
isPhotoUploading.value = true;
try {
const compressedFileList = await compressUploadImageList(fileList);
const tempFileList = await AttachmentAPI.upload(compressedFileList);
for (const item of tempFileList) {
if (!item.id) {
continue;
}
photoItemList.value.push({
tempFileId: item.id,
url: AttachmentAPI.getTempReadURL(item.id)
});
}
} finally {
isPhotoUploading.value = false;
input.value = "";
}
}
// ---------- 登记时间 ----------
const minimumDateTimeText = "1970-01-01 00:00";
const registeredAtText = ref("");
const registeredAtDraftText = ref("");
const registeredAtPickerVisible = ref(false);
function openRegisteredAtPicker(): void {
registeredAtDraftText.value = registeredAtText.value || Time.formatDateTime(new Date());
registeredAtPickerVisible.value = true;
}
function confirmRegisteredAt(value?: string | number): void {
registeredAtText.value = String(value || registeredAtDraftText.value);
registeredAtPickerVisible.value = false;
}
function resetRegisteredAt(): void {
registeredAtText.value = "";
registeredAtDraftText.value = "";
}
function buildRegisteredAt(): number {
return Time.toTimestamp(parseDateTime(registeredAtText.value));
}
function parseDateTime(value: string): Date {
if (!value) {
return new Date();
}
const [dateText, timeText = "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, 0, 0);
}
// ---------- 提交 ----------
const note = ref("");
const isSaving = ref(false);
const editingRecord = ref<EventRecord>();
const canSubmit = computed(() => selectedCustomerList.value.some(item => !!item.id) && 0 < selectedEventIdList.value.length);
async function submitRecord(): Promise<void> {
if (!validateForm()) {
return;
}
isSaving.value = true;
try {
if (isEditMode.value) {
await updateRecord();
return;
}
const customerIdList = selectedCustomerList.value.map(customer => customer.id).filter((item): item is string => !!item);
for (const eventId of selectedEventIdList.value) {
await EventRecordAPI.create({
eventId,
customerIdList,
remark: note.value || "客户现场登记",
registeredAt: buildRegisteredAt(),
attachmentList: buildTempFileIdList().map(tempFileId => ({
tempFileId,
attachType: "PHOTO"
}))
});
}
selectedCustomerList.value = [];
selectedCustomerIdList.value = [];
note.value = "";
photoItemList.value = [];
await loadRecentRecordList();
await settingStore.playRegisterSuccessSound();
Toast.success("登记已提交");
await router.replace("/customers");
} finally {
isSaving.value = false;
}
}
function validateForm(): boolean {
if (!selectedCustomerList.value.some(item => !!item.id)) {
Toast.warning("请先选择或扫码客户");
return false;
}
if (!selectedEventIdList.value.length) {
Toast.warning("请至少选择一个登记事件");
return false;
}
return true;
}
async function updateRecord(): Promise<void> {
const submitCustomer = selectedCustomer.value;
if (!editingRecord.value?.id || !submitCustomer?.id) {
Toast.warning("登记事件记录不存在");
return;
}
await EventRecordAPI.update({
...editingRecord.value,
customerId: submitCustomer.id,
eventId: selectedEventIdList.value[0],
remark: note.value,
registeredAt: buildRegisteredAt(),
attachmentList: buildPhotoAttachmentList()
});
Toast.success("记录已保存");
await router.replace(`/management/registration-event-records/${editingRecord.value.id}`);
}
function buildTempFileIdList(): string[] {
return photoItemList.value.map(item => item.tempFileId).filter((item): item is string => !!item);
}
function buildPhotoAttachmentList(): Array<Partial<Attachment>> {
return photoItemList.value.map((item) => {
if (item.attachment) {
return item.attachment;
}
return {
tempFileId: item.tempFileId,
attachType: "PHOTO"
};
});
}
// ---------- 页面数据 ----------
async function loadPageData(): Promise<void> {
eventList.value = await EventAPI.listValid();
if (isEditMode.value) {
await loadRecordForEdit();
return;
}
selectedEventIdList.value = eventList.value.slice(0, 1).map(item => item.id).filter((item): item is string => Boolean(item));
}
async function loadRecordForEdit(): Promise<void> {
const record = await EventRecordAPI.detail(routeId.value);
editingRecord.value = record;
appendRecordEvent(record);
selectedCustomerList.value = record.customer ? [record.customer] : [];
customerCode.value = record.customer?.code || record.customerId || "";
selectedCustomerIdList.value = record.customer?.id ? [record.customer.id] : [];
selectedEventIdList.value = record.eventId ? [record.eventId] : [];
registeredAtText.value = record.registeredAt ? Time.formatDateTime(new Date(record.registeredAt)) : "";
note.value = record.remark || "";
photoItemList.value = (record.attachmentList || [])
.filter(item => item.attachType === "PHOTO" || !item.attachType)
.filter(item => !!item.id)
.map(item => ({
attachment: item,
url: AttachmentAPI.getReadURL(item.id || "")
}));
await loadRecentRecordList();
}
onMounted(loadPageData);
watch(customerCode, async (value) => {
if (!value) {
return;
}
scanCode.value = value;
});
</script>
<style scoped lang="less">
.registration-record-edit {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
flex-direction: column;
.form,
.record-panel {
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
}
.form {
padding: .5rem 0;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
.customer {
.actions {
gap: .5rem;
display: flex;
}
}
.code-scanner-wrapper,
.photo-part {
padding: 1rem;
}
.textarea {
width: 100%;
}
.customer-profile {
margin: 1rem;
}
.registration-event-selector {
padding: 0 1rem;
margin-top: 1rem;
}
.time-actions {
gap: .5rem;
display: flex;
align-items: center;
justify-content: flex-end;
}
}
.record-photo {
width: 3.5rem;
height: 3.5rem;
border-radius: .875rem;
}
.empty {
color: #52607a;
margin: 0 1rem;
padding: 1rem;
text-align: center;
background: #f7faff;
border-radius: 1rem;
}
.save {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
}
.file-input {
display: none;
}
}
.selected-customer-popup {
height: 70vh;
overflow: auto;
padding-top: 1rem;
border-radius: 1.25rem 1.25rem 0 0;
.popup-header {
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
}
}
.list {
margin-top: 1rem;
}
}
:global(html.theme-dark) {
.registration-record-edit {
:deep(.form-item .t-form__label),
.selector-text p,
.empty-block {
color: #94a3b8;
}
.form,
.record-panel,
.selected-customer-popup {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
:deep(.textarea .t-textarea__inner) {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
.empty-block {
background: rgba(30, 41, 59, .9);
}
.selector-text strong {
color: #e2e8f0;
}
.selector-action {
color: #93c5fd;
}
}
}
</style>
+692
View File
@@ -0,0 +1,692 @@
<template>
<div v-can="permissionCodes.eventRecord.read" class="registration-record-list">
<div class="search-bar">
<t-input
v-model.trim="keyword"
class="search-input"
type="search"
borderless
placeholder="搜索客户、编码、事件、备注、登记人"
/>
<div class="filter-line">
<t-dropdown-menu class="filter-menu">
<t-dropdown-item
v-model="dateFilterType"
:label="dateFilterLabel"
:options="dateOptions"
@change="selectDateFilter"
/>
<t-dropdown-item
v-model="actionFilter"
:label="eventFilterLabel"
options-columns="2"
:options="actionOptions"
multiple
/>
</t-dropdown-menu>
</div>
</div>
<t-cell-group v-if="isCustomRange" class="time-range" theme="card">
<t-cell
title="开始时间"
:note="dateRange[0] || '-'"
@click="startVisible = true"
/>
<t-cell
title="结束时间"
:note="dateRange[1] || '-'"
@click="endVisible = true"
/>
</t-cell-group>
<div class="summary">
<div v-text="summaryText" />
<div v-text="summaryBehaviorText" />
<div v-text="summaryTotalText" />
</div>
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="reloadRecords"
@scrolltolower="loadRecords"
>
<t-cell-group
v-if="pageResult.list.length"
class="record-list"
theme="card"
>
<t-swipe-cell
v-for="item in pageResult.list"
:key="item.id"
class="item"
>
<t-cell
class="cell"
:title="getRecordEventName(item)"
:description="getRecordCustomerName(item)"
:note="item.registeredAt ? Time.toDateTime(item.registeredAt) : '-'"
arrow
hover
@click="toDetail(item.id || '')"
>
<template #leftIcon>
<t-image
v-if="getRecordPhotoURL(item)"
:src="getRecordPhotoURL(item)"
:alt="getRecordEventName(item)"
fit="cover"
class="photo"
/>
<div v-else class="photo fallback" v-text="getRecordCustomerInitial(item)" />
</template>
</t-cell>
<template v-if="canDeleteRecord" #right>
<t-button
class="delete"
theme="danger"
:loading="isDeleting && deletingId === item.id"
@click.stop="confirmRemoveRecord(item)"
>
删除
</t-button>
</template>
</t-swipe-cell>
</t-cell-group>
<div v-else class="load-state" v-text="isLoading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
<div v-if="pageResult.list.length" class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
<t-button
v-can="permissionCodes.eventRecord.export"
class="export"
theme="primary"
:loading="isExporting"
@click="exportExcel"
>
<template #icon>
<t-icon name="download" />
</template>
导出 Excel
</t-button>
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
:default-value="dateRange[0]"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
v-model="dateRange[0]"
@confirm="startVisible = false"
/>
</t-popup>
<t-popup v-model="endVisible" placement="bottom">
<t-date-time-picker
:default-value="dateRange[1]"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="结束时间"
v-model="dateRange[1]"
@confirm="endVisible = false"
/>
</t-popup>
</template>
<script setup lang="ts">
import { DialogPlugin, Toast, type DropdownValue } from "tdesign-mobile-vue";
import { Time, Toolkit, type PageResult, useUserStore } from "timi-web";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventItem } from "@/types/EventItem";
import type { EventRecordListItemView, EventRecordPage } from "@/types/EventRecord";
import { getCustomerPhotoURL } from "@/utils/customer";
import { permissionCodes } from "@/utils/permission";
type DateFilterType = "today" | "yesterday" | "beforeYesterday" | "lastWeek" | "all" | "custom";
defineOptions({
name: "EventRecordList"
});
const pageSize = 20;
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
{ label: "今天", value: "today" },
{ label: "昨天", value: "yesterday" },
{ label: "前天", value: "beforeYesterday" },
{ label: "上周", value: "lastWeek" },
{ label: "全部", value: "all" },
{ label: "自定义范围", value: "custom" }
];
// ---------- 筛选 ----------
const keyword = ref(getQueryText("keyword"));
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
const dateRange = ref(parseDateRange(dateFilterType.value));
const actionFilter = ref<string[]>([]);
const eventList = ref<EventItem[]>([]);
const startVisible = ref(false);
const endVisible = ref(false);
let keywordTimer: number | undefined;
let initialized = false;
const canCreateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.create));
const isCustomRange = computed(() => dateFilterType.value === "custom");
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "今天");
const actionOptions = computed(() => eventList.value.map(item => ({
label: item.name || "未命名事件",
value: item.id || ""
})).filter(item => item.value));
const eventFilterLabel = computed(() => {
if (!actionFilter.value.length || actionFilter.value.length === eventList.value.length) {
return "全部事件";
}
return eventList.value
.filter(item => item.id && actionFilter.value.includes(item.id))
.map(item => item.name || "未命名事件")
.join("、");
});
const summaryBehaviorText = computed(() => `事件:${eventFilterLabel.value}`);
const canDeleteRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.delete));
const summaryText = computed(() => {
if (!isCustomRange.value) {
const current = dateOptions.find(item => item.value === dateFilterType.value);
return `范围:${current?.label || "今天"}|${dateRange.value[0] || "不限"}`;
}
return `范围:${dateRange.value[0] || "-"} ~ ${dateRange.value[1] || "-"}`;
});
watch(keyword, () => {
if (!initialized) {
return;
}
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
await replaceFilterQuery();
await reloadRecords();
}, 300);
});
watch(actionFilter, async () => {
if (!initialized) {
return;
}
await replaceFilterQuery();
await reloadRecords();
});
watch(dateRange, async (value) => {
if (!initialized || dateFilterType.value !== "custom") {
return;
}
if (value[0] && value[1] && value[1] < value[0]) {
dateRange.value = [value[1], value[0]];
return;
}
await replaceFilterQuery();
await reloadRecords();
}, { deep: true });
// ---------- 列表分页 ----------
const isRefreshing = ref(false);
const isLoading = ref(false);
const isNoMore = ref(false);
const page = reactive<EventRecordPage>({
index: 0,
size: pageSize
});
const pageResult = reactive<PageResult<EventRecordListItemView>>({
total: 0,
list: []
});
let loadToken = 0;
const summaryTotalText = computed(() => `共 ${pageResult.total} 条数据,已加载 ${pageResult.list.length} 条`);
const loadStateText = computed(() => {
if (isLoading.value) {
return "正在加载更多";
}
return isNoMore.value ? "没有更多记录" : "上拉加载更多";
});
async function reloadRecords(): Promise<void> {
isRefreshing.value = true;
try {
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = false;
await loadRecords();
} finally {
isRefreshing.value = false;
}
}
async function loadRecords(): Promise<void> {
if (isLoading.value || isNoMore.value) {
return;
}
const token = loadToken + 1;
loadToken = token;
isLoading.value = true;
try {
const result = await EventRecordAPI.list({
index: page.index,
size: page.size,
...buildRangeReq()
});
if (token !== loadToken) {
return;
}
pageResult.list = pageResult.list.concat(result.list || []);
pageResult.total = result.total || 0;
page.index += 1;
isNoMore.value = pageResult.list.length >= pageResult.total || (result.list || []).length < page.size;
} finally {
if (token === loadToken) {
isLoading.value = false;
}
}
}
// ---------- 操作 ----------
const isExporting = ref(false);
const isDeleting = ref(false);
const deletingId = ref("");
async function loadActions(): Promise<void> {
eventList.value = await EventAPI.listValid();
const eventIdList = parseEventIdList(route.query.eventIds);
actionFilter.value = eventIdList.length
? eventIdList
: eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
}
async function selectDateFilter(type: DropdownValue): Promise<void> {
if (Array.isArray(type)) {
return;
}
dateFilterType.value = String(type) as DateFilterType;
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
await replaceFilterQuery();
await reloadRecords();
} else {
await replaceFilterQuery();
}
}
async function exportExcel(): Promise<void> {
if (isExporting.value) {
return;
}
isExporting.value = true;
try {
const blob = await EventRecordAPI.exportXSSF({
index: 0,
size: pageSize,
...buildRangeReq()
});
Toolkit.downloadBlob(blob, `登记事件记录-${Time.formatDate(new Date())}.xlsx`);
Toast.success("导出已开始");
} finally {
isExporting.value = false;
}
}
async function toDetail(id: string): Promise<void> {
if (!id) {
return;
}
await router.push({
path: `/management/registration-event-records/${id}`,
query: buildFilterQuery()
});
}
async function toCreate(): Promise<void> {
await router.push("/management/registration-event-records/create");
}
async function confirmRemoveRecord(record: EventRecordListItemView): Promise<void> {
if (!record.id) {
return;
}
const recordId = record.id;
const dialog = DialogPlugin.confirm({
title: "删除登记记录",
content: `确定删除 ${getRecordCustomerName(record)} 的 ${getRecordEventName(record)} 记录吗?删除后无法恢复。`,
cancelBtn: "取消",
confirmBtn: "删除",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await removeRecord(recordId);
}
});
}
async function removeRecord(id: string): Promise<void> {
if (isDeleting.value) {
return;
}
isDeleting.value = true;
deletingId.value = id;
try {
await EventRecordAPI.remove(id);
pageResult.list = pageResult.list.filter(item => item.id !== id);
pageResult.total = Math.max(0, pageResult.total - 1);
Toast.success("登记记录已删除");
} finally {
isDeleting.value = false;
deletingId.value = "";
}
}
// ---------- 格式化 ----------
function getRecordEventName(record: EventRecordListItemView): string {
return record.event?.name || "未知事件";
}
function getRecordCustomerName(record: EventRecordListItemView): string {
return record.customer?.name || "未知客户";
}
function getRecordPhotoURL(record: EventRecordListItemView): string {
return getCustomerPhotoURL(record.customer);
}
function getRecordCustomerInitial(record: EventRecordListItemView): string {
return getRecordCustomerName(record).slice(0, 1);
}
function buildRangeReq(): Omit<EventRecordPage, "index" | "size"> {
const [beginAt, endAt] = buildUnixRange();
const selectedAllEvent = !actionFilter.value.length || actionFilter.value.length === eventList.value.length;
return {
beginAt,
endAt,
keyword: keyword.value.trim() || undefined,
eventIdList: selectedAllEvent ? undefined : actionFilter.value
};
}
function getQueryText(key: string): string {
const value = route.query[key];
return Array.isArray(value) ? String(value[0] || "") : String(value || "");
}
function parseDateFilterType(value: unknown): DateFilterType {
const text = Array.isArray(value) ? String(value[0] || "") : String(value || "");
return dateOptions.some(item => item.value === text) ? text as DateFilterType : "today";
}
function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") {
return buildPresetRange(type);
}
const beginAt = getQueryText("beginAt");
const endAt = getQueryText("endAt");
return [beginAt, endAt];
}
function parseEventIdList(value: unknown): string[] {
const text = Array.isArray(value) ? String(value[0] || "") : String(value || "");
return text.split(",").map(item => item.trim()).filter(Boolean);
}
function buildFilterQuery(): Record<string, string> {
return {
keyword: keyword.value.trim(),
dateType: dateFilterType.value,
beginAt: dateRange.value[0] || "",
endAt: dateRange.value[1] || "",
eventIds: actionFilter.value.join(",")
};
}
async function replaceFilterQuery(): Promise<void> {
await router.replace({
name: "EventRecordList",
query: buildFilterQuery()
});
}
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = new Date();
current.setHours(0, 0, 0, 0);
const day = current.getDay() || 7;
const thisWeekMonday = new Date(current);
thisWeekMonday.setDate(current.getDate() - day + 1);
const lastWeekMonday = new Date(thisWeekMonday);
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
const lastWeekSunday = new Date(thisWeekMonday);
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek">, number> = {
today: 0,
yesterday: 1,
beforeYesterday: 2
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
const value = `${Time.formatDate(date)} 00:00`;
return [value, `${Time.formatDate(date)} 23:59`];
}
function buildUnixRange(): [number, number] {
if (dateFilterType.value === "all") {
return [0, Time.toTimestamp(new Date())];
}
return [
Time.toTimestamp(parseDateTime(dateRange.value[0], false)),
Time.toTimestamp(parseDateTime(dateRange.value[1], true))
];
}
function parseDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
onMounted(async () => {
navBarStore.clearRight();
if (canCreateRecord.value) {
navBarStore.setRightText("添加", "var(--td-brand-color)", toCreate);
}
await loadActions();
initialized = true;
await reloadRecords();
});
onUnmounted(() => {
navBarStore.clearRight();
window.clearTimeout(keywordTimer);
});
</script>
<style scoped lang="less">
.registration-record-list {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height)) 0 6rem 0;
flex-direction: column;
.search-bar {
top: var(--app-nav-height);
left: 0;
right: 0;
z-index: 10;
display: flex;
padding: .75rem 1rem;
position: sticky;
background: #F5F5F8;
box-shadow: var(--td-shadow-1);
flex-direction: column;
.search-input {
--td-input-vertical-padding: 8px;
border: 1px solid rgba(148, 163, 184, .28);
z-index: 1;
background: #f8fbff;
border-radius: 1rem;
}
.filter-menu {
--td-spacer-1: 6px;
width: 100%;
overflow: hidden;
margin-top: -1rem;
padding-top: .8rem;
background: #fff;
border: 1px solid rgba(148, 163, 184, .18);
border-radius: 0 0 1rem 1rem;
&:after {
display: none;
}
:deep(.t-dropdown-menu__item) {
min-width: 0;
}
:deep(.t-dropdown-menu__title) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.summary {
color: #52607a;
font-size: .8125rem;
text-align: center;
}
.record-list {
.item {
&:last-child {
:deep(.t-cell:after) {
display: none;
}
}
.cell {
padding: .5rem;
:deep(.t-cell__title) {
flex: 1 1 0;
min-width: 0;
}
:deep(.t-cell__title-text) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:deep(.t-cell__note) {
flex: 0 0 auto;
white-space: nowrap;
}
.photo {
width: 3.5rem;
height: 3.5rem;
border-radius: .5rem;
&.fallback {
color: #2563eb;
display: flex;
font-size: 1.25rem;
background: #dbeafe;
align-items: center;
font-weight: 600;
justify-content: center;
}
}
}
.delete {
height: 100%;
padding: 0 1rem;
border-radius: 0;
}
}
}
}
.export {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
z-index: 20;
position: fixed;
box-shadow: 0 -.375rem 1.25rem rgba(15, 23, 42, .08);
backdrop-filter: blur(.75rem);
}
:global(html.theme-dark) {
.registration-record-list {
.search-bar {
background: #10161f;
.search-input {
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
.filter-menu {
background: #172033;
border-color: rgba(148, 163, 184, .16);
}
}
.empty-block,
.load-state,
.summary {
color: #94a3b8;
}
}
.bottom-bar {
background: rgba(15, 23, 42, .88);
box-shadow: 0 -.375rem 1.25rem rgba(2, 6, 23, .32);
}
}
</style>
+359
View File
@@ -0,0 +1,359 @@
<template>
<div v-can="permissionCodes.event.update" class="registration-event-sort">
<div class="tip" v-text="'按住右侧拖动手柄调整事件顺序,保存后生效'" />
<div ref="sortListRef" class="items">
<t-cell-group v-if="eventList.length" theme="card">
<t-cell
class="cell"
:title="item.name || '未命名事件'"
v-for="(item, index) in eventList"
: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="isLoading ? '正在加载登记事件' : '暂无登记事件'" />
</div>
<t-button
class="save"
theme="primary"
:loading="isSaving"
:disabled="!sortDirty || isSaving || isLoading"
@click="saveSort"
>
保存排序
</t-button>
</div>
</template>
<script setup lang="ts">
import { Toast, type TagProps } from "tdesign-mobile-vue";
import EventAPI from "@/api/EventAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventItem, EventStatus } from "@/types/EventItem";
import { permissionCodes } from "@/utils/permission";
defineOptions({
name: "EventSort"
});
const navBarStore = useNavBarStore();
// ---------- 列表数据 ----------
const defaultColor = "#2f88ff";
const eventList = ref<EventItem[]>([]);
const sortListRef = ref<HTMLElement>();
const isLoading = ref(false);
const isSaving = 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?: EventStatus): string {
if (status === "DRAFT") {
return "草稿";
}
if (status === "INACTIVE") {
return "不可登记";
}
return "可登记";
}
function getStatusTheme(status?: EventStatus): 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] = eventList.value.splice(draggingIndex.value, 1);
eventList.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 (isSaving.value || isLoading.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 (isSaving.value || isLoading.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 || isSaving.value) {
return;
}
isSaving.value = true;
try {
const idList = eventList.value
.map(item => item.id)
.filter((id): id is string => Boolean(id));
await EventAPI.sort(idList);
eventList.value.forEach((item, index) => {
item.sort = index;
});
sortDirty.value = false;
Toast.success("登记事件排序已保存");
} catch {
Toast.error("登记事件排序保存失败");
} finally {
isSaving.value = false;
}
}
// ---------- 页面数据 ----------
async function loadData(): Promise<void> {
isLoading.value = true;
try {
const result = await EventAPI.list({
index: 0,
size: 100
});
eventList.value = result.list || [];
sortDirty.value = false;
} finally {
isLoading.value = false;
}
}
onMounted(async () => {
navBarStore.clearRight();
try {
await loadData();
} catch {
Toast.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>