update registration event editor

This commit is contained in:
Timi
2026-08-02 00:20:13 +08:00
parent 11c48bf00c
commit 35dff786b1
10 changed files with 376 additions and 215 deletions
+1 -1
View File
@@ -184,7 +184,7 @@ onUnmounted(() => {
<style scoped lang="less">
.customer-selector {
display: flex;
height: 70vh;
height: 80vh;
padding-top: 1rem;
border-radius: 1.25rem 1.25rem 0 0;
flex-direction: column;
+21 -1
View File
@@ -5,6 +5,8 @@
theme="primary"
variant="dashed"
class="add"
:loading="loading"
:disabled="loading"
@click="$emit('add')"
>
<span v-text="addText" />
@@ -18,8 +20,11 @@
fit="cover"
/>
</t-button>
<div v-if="loading" class="mask">
<t-loading size="1.5rem" text="上传中" />
</div>
<t-button
v-if="showRemove"
v-if="showRemove && !loading"
class="remove"
theme="danger"
shape="circle"
@@ -42,6 +47,7 @@ interface Props {
addText?: string;
altText?: string;
modelValue: string;
loading?: boolean;
preview?: boolean;
showRemove?: boolean;
size?: "normal" | "compact";
@@ -50,6 +56,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
addText: "拍照",
altText: "照片",
loading: false,
preview: true,
showRemove: true,
size: "normal"
@@ -133,6 +140,19 @@ function clearPhoto(): void {
}
}
.mask {
inset: 0;
gap: .375rem;
z-index: 1;
color: #fff;
display: flex;
position: absolute;
align-items: center;
flex-direction: column;
justify-content: center;
background: rgba(15, 23, 42, .45);
}
&.compact {
.add,
.card {
+4
View File
@@ -29,6 +29,8 @@
theme="primary"
variant="dashed"
class="add"
:loading="loading"
:disabled="loading"
@click="$emit('add')"
>
<span v-text="addText" />
@@ -47,6 +49,7 @@ interface Props {
addText?: string;
emptyText?: string;
fallbackImage?: string;
loading?: boolean;
modelValue: string[];
showAdd?: boolean;
showRemove?: boolean;
@@ -57,6 +60,7 @@ const props = withDefaults(defineProps<Props>(), {
title: "照片列表",
addText: "拍照",
emptyText: "暂无照片",
loading: false,
showAdd: true,
showRemove: true
});
+14
View File
@@ -52,6 +52,7 @@
<photo-field
v-model="supplementPhoto"
alt-text="客户照片"
:loading="supplementPhotoUploading"
:preview="false"
@add="selectSupplementPhoto"
@clear="clearSupplementPhoto"
@@ -76,6 +77,7 @@
<photo-scroller
title="登记事件照片"
v-model="photoList"
:loading="recordPhotoUploading"
@add="selectRecordPhoto"
/>
</div>
@@ -133,6 +135,8 @@ const supplementPhotoTempFileId = ref("");
const supplementPhotoInputRef = ref<HTMLInputElement>();
const recordPhotoInputRef = ref<HTMLInputElement>();
const recordPhotoTempFileIdList = ref<string[]>([]);
const supplementPhotoUploading = ref(false);
const recordPhotoUploading = ref(false);
const registrationEventList = ref<RegistrationEvent[]>([]);
const selectedRegistrationEventIdList = ref<string[]>([]);
const note = ref("");
@@ -375,6 +379,8 @@ async function handleSupplementPhotoSelected(event: Event): Promise<void> {
if (!file) {
return;
}
supplementPhotoUploading.value = true;
try {
const compressedFile = await compressUploadImage(file);
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
if (!tempFile?.id) {
@@ -383,7 +389,10 @@ async function handleSupplementPhotoSelected(event: Event): Promise<void> {
}
supplementPhotoTempFileId.value = tempFile.id;
supplementPhoto.value = AttachmentAPI.getTempReadURL(tempFile.id);
} finally {
supplementPhotoUploading.value = false;
input.value = "";
}
}
function selectRecordPhoto(): void {
@@ -396,6 +405,8 @@ async function handleRecordPhotoSelected(event: Event): Promise<void> {
if (!fileList.length) {
return;
}
recordPhotoUploading.value = true;
try {
const compressedFileList = await compressUploadImageList(fileList);
const tempFileList = await AttachmentAPI.uploadTemp(compressedFileList);
for (const item of tempFileList) {
@@ -405,7 +416,10 @@ async function handleRecordPhotoSelected(event: Event): Promise<void> {
recordPhotoTempFileIdList.value.push(item.id);
photoList.value.push(AttachmentAPI.getTempReadURL(item.id));
}
} finally {
recordPhotoUploading.value = false;
input.value = "";
}
}
// ---------- 提交 ----------
+7
View File
@@ -46,6 +46,7 @@
<photo-field
v-model="formModel.photoURL"
alt-text="客户照片"
:loading="photoUploading"
:preview="false"
@add="selectPhoto"
@clear="resetPhotoState"
@@ -177,6 +178,7 @@ const selectorVisible = ref(false);
const referrerCustomerId = ref("");
const photoInputRef = ref<HTMLInputElement>();
const photoTempFileId = ref("");
const photoUploading = ref(false);
const scannerVisible = ref(false);
const scannerTarget = ref<ScanTarget>("code");
const scanStatus = ref("点击扫码开始识别。");
@@ -321,6 +323,8 @@ async function handlePhotoSelected(event: Event): Promise<void> {
if (!file) {
return;
}
photoUploading.value = true;
try {
const compressedFile = await compressUploadImage(file);
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
if (!tempFile?.id) {
@@ -329,7 +333,10 @@ async function handlePhotoSelected(event: Event): Promise<void> {
}
photoTempFileId.value = tempFile.id;
formModel.photoURL = AttachmentAPI.getTempReadURL(tempFile.id);
} finally {
photoUploading.value = false;
input.value = "";
}
}
// ---------- 介绍人 ----------
+26 -3
View File
@@ -78,6 +78,11 @@ const monthRecords = ref<RegistrationEventRecord[]>([]);
const loading = ref(false);
const calendarLoading = ref(false);
const monthCursor = ref(getMonthStart(new Date()));
const genderColorMap = {
MALE: "#2563eb",
FEMALE: "#db2777",
UNKNOWN: "#64748b"
} as const;
const beginDate = getDateStart(addDays(new Date(), -6));
const endDate = getDateEnd(new Date());
const dateTextList = buildDateTextList(beginDate, endDate);
@@ -305,6 +310,9 @@ function buildRecordTrendOption(): EChartsOption {
function buildEventStructureOption(): EChartsOption {
return {
...buildBaseOption(),
legend: {
show: false
},
xAxis: {
type: "category",
data: registrationEventList.value.map(item => item.name || "未命名事件"),
@@ -345,9 +353,24 @@ function buildGenderStatOption(): EChartsOption {
name: "客户数量",
type: "bar",
data: [
getGenderCount("MALE"),
getGenderCount("FEMALE"),
getGenderCount("UNKNOWN")
{
value: getGenderCount("MALE"),
itemStyle: {
color: genderColorMap.MALE
}
},
{
value: getGenderCount("FEMALE"),
itemStyle: {
color: genderColorMap.FEMALE
}
},
{
value: getGenderCount("UNKNOWN"),
itemStyle: {
color: genderColorMap.UNKNOWN
}
}
]
}]
};
@@ -7,6 +7,7 @@
add-text="更换"
alt-text="用户头像"
size="compact"
:loading="avatarUploading"
:preview="false"
:show-remove="false"
@add="changeAvatar"
@@ -63,6 +64,7 @@ const router = useRouter();
const userStore = useUserStore();
const avatarInputRef = ref<HTMLInputElement>();
const avatarTempFileId = ref("");
const avatarUploading = ref(false);
const isSaving = ref(false);
const formModel = reactive({
@@ -108,6 +110,8 @@ async function handleAvatarSelected(event: Event): Promise<void> {
if (!file) {
return;
}
avatarUploading.value = true;
try {
const compressedFile = await compressUploadImage(file);
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
if (!tempFile?.id) {
@@ -120,7 +124,10 @@ async function handleAvatarSelected(event: Event): Promise<void> {
}
avatarTempFileId.value = tempFile.id;
formModel.avatar = AttachmentAPI.getTempReadURL(tempFile.id);
} finally {
avatarUploading.value = false;
input.value = "";
}
}
async function goPassword(): Promise<void> {
+183 -17
View File
@@ -8,10 +8,17 @@
<t-textarea v-model.trim="formModel.description" class="textarea" placeholder="事件说明" autosize borderless />
</t-form-item>
<t-form-item label="颜色">
<label class="color-picker">
<input v-model="formModel.color" type="color">
<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" />
</label>
</div>
</t-button>
</t-form-item>
<t-form-item label="状态">
<t-radio-group v-model="formModel.status" class="inline-options" variant="default-filled">
@@ -19,10 +26,10 @@
<t-radio :block="false" borderless value="INACTIVE">无效</t-radio>
</t-radio-group>
</t-form-item>
<t-form-item label="启用">
<t-form-item label="启用" content-align="right">
<t-switch v-model="formModel.enabled" />
</t-form-item>
<t-form-item label="拍照">
<t-form-item label="需要拍照" content-align="right">
<t-switch v-model="formModel.requirePhoto" />
</t-form-item>
<t-form-item label="排序">
@@ -62,12 +69,50 @@
</t-checkbox>
</t-checkbox-group>
</section>
<t-button v-can="savePermissionCode" class="save" theme="primary" @click="saveRegistrationEvent">保存</t-button>
<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="confirmRemoveRegistrationEvent"
>
删除事件
</t-button>
<t-button
v-can="savePermissionCode"
class="save"
theme="primary"
:loading="saving"
:disabled="removing"
@click="saveRegistrationEvent"
>
保存
</t-button>
</div>
</div>
<t-popup v-model="colorPickerVisible" placement="bottom">
<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-model="colorDraft"
format="HEX8"
type="multiple"
:swatch-colors="colorSwatchList"
/>
</div>
</t-popup>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
import type { Role } from "timi-web";
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
import GaoUserAPI from "@/api/GaoUserAPI";
@@ -88,11 +133,30 @@ const router = useRouter();
const routeId = computed(() => String(route.params.id || ""));
const isCreateMode = computed(() => !routeId.value);
const savePermissionCode = computed(() => isCreateMode.value ? gaoPermissionCodes.registrationEvent.create : gaoPermissionCodes.registrationEvent.update);
const deletePermissionCode = gaoPermissionCodes.registrationEvent.delete;
const pageAccess = computed(() => ({
value: [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read, savePermissionCode.value],
value: isCreateMode.value
? [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read, savePermissionCode.value]
: [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read, { value: [savePermissionCode.value, deletePermissionCode], mode: "or" as const }],
mode: "and" as const
}));
const roleOptions = ref<Role[]>([]);
const colorPickerVisible = ref(false);
const colorDraft = ref("#2f88ff");
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" },
@@ -116,6 +180,21 @@ const formModel = reactive({
});
const limitValuePlaceholder = computed(() => formModel.limitType === "INTERVAL_HOUR" ? "请输入间隔小时数" : "请输入累计上限次数");
function openColorPicker(): void {
colorDraft.value = formModel.color;
colorPickerVisible.value = true;
}
function cancelColorPicker(): void {
colorPickerVisible.value = false;
colorDraft.value = formModel.color;
}
function confirmColorPicker(): void {
formModel.color = colorDraft.value;
colorPickerVisible.value = false;
}
function isValueLimitType(value: RegistrationLimitType): boolean {
return value === "INTERVAL_HOUR" || value === "TOTAL_LIMIT";
}
@@ -162,6 +241,7 @@ async function saveRegistrationEvent(): Promise<void> {
return;
}
saving.value = true;
try {
const payload = buildPayload();
if (isCreateMode.value) {
@@ -173,6 +253,47 @@ async function saveRegistrationEvent(): Promise<void> {
await router.replace("/management/registration-events");
} catch {
showToast("登记事件保存失败", "error");
} finally {
saving.value = false;
}
}
async function confirmRemoveRegistrationEvent(): 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 removeRegistrationEvent(id);
}
});
}
async function removeRegistrationEvent(id: string): Promise<void> {
if (removing.value) {
return;
}
removing.value = true;
try {
await RegistrationEventAPI.remove(id);
showToast("登记事件已删除");
await router.replace("/management/registration-events");
} catch {
showToast("登记事件删除失败", "error");
} finally {
removing.value = false;
}
}
@@ -204,6 +325,7 @@ async function fillForm(): Promise<void> {
sortText: String(current.sort ?? 0),
roleCodeList: [...(current.roleCodeList || [])]
});
colorDraft.value = formModel.color;
}
onMounted(async () => {
@@ -227,17 +349,24 @@ onMounted(async () => {
width: 100%;
}
.color-picker {
.color-trigger {
--td-button-extra-small-padding-horizontal: 2px;
gap: .75rem;
display: flex;
align-items: center;
padding-right: .875rem;
justify-content: flex-start;
input {
padding: 0;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
.content {
gap: .5rem;
display: flex;
.swatch {
width: 1.5rem;
height: 1.5rem;
display: block;
border-radius: .25rem;
}
span {
@@ -245,6 +374,7 @@ onMounted(async () => {
font-size: .875rem;
}
}
}
.inline-options,
.limit-options {
@@ -289,21 +419,57 @@ onMounted(async () => {
}
}
.save {
.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;
}
}
}
.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-picker span,
.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);
+40 -126
View File
@@ -1,31 +1,31 @@
<template>
<div v-can="{ value: [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read], mode: 'and' }" class="registration-event-list">
<article
<t-cell-group theme="card">
<t-cell
class="item"
v-for="item in registrationEventList"
:key="item.id"
:arrow="canUpdateRegistrationEvent"
:hover="canUpdateRegistrationEvent"
@click="handleRegistrationEventClick(item)"
>
<div class="main">
<span class="color" :style="{ background: item.color || defaultColor }" />
<div class="content">
<div class="title">
<strong v-text="item.name || '未命名事件'" />
<template #title>
<div class="header">
<div class="color" :style="{ background: item.color || defaultColor }" />
<div class="title" v-text="item.name || '未命名事件'" />
<t-tag
size="small"
theme="primary"
:variant="isRegistrationEventActive(item) ? 'light' : 'outline'"
class="status"
:theme="isRegistrationEventActive(item) ? 'primary' : 'default'"
variant="outline"
:content="getStatusText(item)"
/>
<t-tag v-if="item.requirePhoto" theme="warning" variant="outline">需拍照</t-tag>
</div>
<p v-if="item.description" v-text="item.description" />
<div class="meta">
<span v-text="getLimitText(item)" />
<span v-if="item.requirePhoto">需拍照</span>
<span v-text="`${item.roleCodeList?.length || 0} 个角色`" />
</div>
</div>
</div>
<div class="roles">
</template>
<template #description>
<div class="description">
<div class="line">
<span>可登记角色</span>
<t-tag
v-for="roleCode in item.roleCodeList"
:key="roleCode"
@@ -34,33 +34,11 @@
:content="getRoleName(roleCode)"
/>
</div>
<div class="actions">
<t-button
v-can="gaoPermissionCodes.registrationEvent.update"
theme="primary"
variant="outline"
size="small"
@click="editRegistrationEvent(item)"
content="编辑"
>
<template #icon>
<t-icon name="edit-1" size="small" />
</template>
</t-button>
<t-button
v-can="gaoPermissionCodes.registrationEvent.delete"
theme="danger"
variant="outline"
size="small"
@click="removeRegistrationEvent(item)"
content="删除"
>
<template #icon>
<t-icon name="delete" size="small" />
</template>
</t-button>
<div class="line" v-text="getLimitText(item)" />
</div>
</article>
</template>
</t-cell>
</t-cell-group>
<div v-if="!registrationEventList.length" class="empty-block">暂无登记事件</div>
<t-button v-can="gaoPermissionCodes.registrationEvent.create" class="create" theme="primary" @click="createRegistrationEvent">
<template #icon>
@@ -73,7 +51,7 @@
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import type { Role } from "timi-web";
import { useUserStore, type Role } from "timi-web";
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
import GaoUserAPI from "@/api/GaoUserAPI";
import type { RegistrationEvent, RegistrationLimitType } from "@/types/RegistrationEvent";
@@ -84,9 +62,11 @@ defineOptions({
});
const router = useRouter();
const userStore = useUserStore();
const defaultColor = "#2f88ff";
const registrationEventList = ref<RegistrationEvent[]>([]);
const roleOptions = ref<Role[]>([]);
const canUpdateRegistrationEvent = computed(() => userStore.hasPermissions(gaoPermissionCodes.registrationEvent.update));
function createRegistrationEvent(): void {
router.push("/management/registration-events/create");
@@ -100,26 +80,12 @@ function editRegistrationEvent(item: RegistrationEvent): void {
router.push(`/management/registration-events/${item.id}/edit`);
}
async function removeRegistrationEvent(item: RegistrationEvent): Promise<void> {
if (!item.id) {
function handleRegistrationEventClick(item: RegistrationEvent): void {
if (!canUpdateRegistrationEvent.value) {
return;
}
try {
await RegistrationEventAPI.remove(item.id);
Toast({
theme: "success",
direction: "row",
message: "登记事件已删除"
});
await loadData();
} catch {
Toast({
theme: "error",
direction: "row",
message: "登记事件删除失败"
});
}
editRegistrationEvent(item);
}
function isRegistrationEventActive(item: RegistrationEvent): boolean {
@@ -180,83 +146,31 @@ onMounted(async () => {
<style scoped lang="less">
.registration-event-list {
gap: .875rem;
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
display: flex;
flex-direction: column;
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
.item {
gap: .75rem;
padding: 1rem;
display: grid;
border: 1px solid rgba(148, 163, 184, .16);
background: rgba(255, 255, 255, .96);
box-shadow: var(--td-shadow-1);
border-radius: .75rem;
.main {
gap: .75rem;
.header {
gap: .5rem;
width: 100%;
display: flex;
min-width: 0;
align-items: flex-start;
margin-top: .25rem;
.color {
width: .875rem;
height: .875rem;
flex: 0 0 auto;
margin-top: .25rem;
border-radius: 50%;
}
.content {
flex: 1;
min-width: 0;
.title {
gap: .5rem;
display: flex;
min-width: 0;
align-items: center;
strong {
overflow: hidden;
font-size: 1rem;
font-weight: 700;
white-space: nowrap;
text-overflow: ellipsis;
width: 1.5rem;
height: 1.5rem;
border-radius: .4rem;
}
}
p {
margin: .375rem 0 0 0;
color: #52607a;
font-size: .875rem;
}
.meta {
gap: .5rem;
display: flex;
.description {
margin-top: .5rem;
flex-wrap: wrap;
span {
color: #52607a;
font-size: .75rem;
}
}
}
}
.roles {
gap: .375rem;
display: flex;
flex-wrap: wrap;
}
.actions {
.line {
gap: .5rem;
display: flex;
justify-content: flex-end;
align-items: center;
}
}
}
@@ -67,7 +67,7 @@
</t-form-item>
<div class="photo-part">
<photo-scroller v-model="photoList" @add="capturePhoto" />
<photo-scroller v-model="photoList" :loading="photoUploading" @add="capturePhoto" />
</div>
</t-form>
<t-cell-group
@@ -161,6 +161,7 @@ const registeredAtPickerVisible = ref(false);
const note = ref("");
const recordPhotoInputRef = ref<HTMLInputElement>();
const photoItemList = ref<EditablePhoto[]>([]);
const photoUploading = ref(false);
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
const saving = ref(false);
@@ -366,6 +367,8 @@ async function handleRecordPhotoSelected(event: Event): Promise<void> {
if (!fileList.length) {
return;
}
photoUploading.value = true;
try {
const compressedFileList = await compressUploadImageList(fileList);
const tempFileList = await AttachmentAPI.uploadTemp(compressedFileList);
for (const item of tempFileList) {
@@ -377,7 +380,10 @@ async function handleRecordPhotoSelected(event: Event): Promise<void> {
url: AttachmentAPI.getTempReadURL(item.id)
});
}
} finally {
photoUploading.value = false;
input.value = "";
}
}
// ---------- 提交 ----------