update AIChart, customer followup, media selector

This commit is contained in:
Timi
2026-09-18 17:38:55 +08:00
parent c6b398fa98
commit 57c4275a23
57 changed files with 3866 additions and 2071 deletions
@@ -0,0 +1,645 @@
<template>
<div v-can="pageAccess" class="follow-up-reminder-edit">
<t-form class="form" label-width="6rem">
<t-form-item label="客户" content-align="right" required-mark>
<div class="customer-picker">
<span v-if="!selectedCustomer" class="picker-text" v-text="'请选择客户'" />
<t-button
theme="primary"
size="extra-small"
content="选择"
@click="customerSelectorVisible = true"
/>
</div>
</t-form-item>
<t-form-item v-if="selectedCustomer" label-width="0">
<customer-profile-card class="customer-profile" :customer="selectedCustomer" />
</t-form-item>
<t-form-item
label="负责人"
content-align="right"
required-mark
arrow
@click="responsibleSelectorVisible = true"
>
<span v-text="selectedResponsibleName" />
</t-form-item>
<t-form-item label="跟进内容" required-mark>
<t-textarea
v-model="formModel.content"
class="textarea"
:autosize="{ minRows: 1, maxRows: 8 }"
maxlength="1000"
borderless
placeholder="请输入跟进内容"
/>
</t-form-item>
<t-form-item label-width="0">
<media-selector
:model-value="photoUrlList"
v-model:photo-items="photoItems"
:upload="uploadReminderPhotoList"
:edit-mode="true"
:loading="isSaving || isUploading"
add-text="选择照片"
:show-type-label="false"
title="附件照片"
/>
</t-form-item>
</t-form>
<t-form class="form">
<t-form-item class="remind-at" label="提醒时间" content-align="right" required-mark>
<span class="picker-text" v-text="formModel.remindAtText || '请选择提醒时间'" />
</t-form-item>
<t-form-item label-width="0" content-align="right">
<t-radio-group
v-model="selectedRemindAtPreset"
@change="handleRemindAtPresetChange"
>
<t-radio :block="false" borderless value="tomorrow">明天</t-radio>
<t-radio :block="false" borderless value="afterTomorrow">后天</t-radio>
<t-radio :block="false" borderless value="custom">指定时间</t-radio>
</t-radio-group>
</t-form-item>
<t-form-item label="站内通知" content-align="right">
<t-switch
size="small"
:model-value="hasChannel('INTERNAL')"
borderless
@change="value => setChannel('INTERNAL', value === true)"
/>
</t-form-item>
<t-form-item label="短信通知" content-align="right">
<t-switch
size="small"
:model-value="hasChannel('SMS')"
:disabled="!canSelectSms"
borderless
@change="value => setChannel('SMS', value === true)"
/>
</t-form-item>
</t-form>
<div class="tips red"
v-text="'提醒时间精确到分钟,不能早于当前时间。短信会在提醒日期 08:00 聚合发送,不会在你选择的精确时间发送。'" />
<customer-selector
v-model="formModel.customerId"
v-model:visible="customerSelectorVisible"
:store-id="customerPickerStoreId"
@change="handleCustomerChange"
/>
<managed-user-selector
v-model="formModel.responsibleUserId"
v-model:visible="responsibleSelectorVisible"
:store-id="responsiblePickerStoreId"
@change="handleResponsibleChange"
/>
<t-popup v-model="remindAtPickerVisible" placement="bottom">
<t-date-time-picker
v-if="remindAtPickerVisible"
v-model="remindAtDraftText"
:start="minimumDateTimeText"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="提醒时间"
@cancel="cancelRemindAtPicker"
@confirm="confirmRemindAt"
/>
</t-popup>
</div>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { AttachmentAPI, type User, useUserStore } from "timi-web-api";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import { Message } from "@/utils/message";
import { getErrorMessage } from "@/utils/error";
import FollowUpReminderAPI from "@/api/FollowUpReminderAPI";
import CustomerAPI from "@/api/CustomerAPI";
import CustomerSelector from "@/components/CustomerSelector.vue";
import ManagedUserSelector from "@/pages/followUpReminder/component/ManagedUserSelector.vue";
import MediaSelector from "@/components/mediaSelector/MediaSelector.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer";
import type { ManagedUser } from "@/types/ManagedUser";
import type {
FollowUpReminder,
FollowUpReminderAttachment,
FollowUpReminderNotifyChannel,
FollowUpReminderPayload
} from "@/types/FollowUpReminder";
import type { PhotoItem } from "@/types/Photo";
import { compressUploadImageList } from "@/utils/imageCompression";
import { getAttachmentReadURL } from "@/utils/attachment";
import { parseDateTime } from "@/utils/date";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
name: "FollowUpReminderEdit"
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const navBarStore = useNavBarStore();
const routeId = computed(() => String(route.params.id || ""));
const isEditMode = computed(() => !!routeId.value);
const pageAccess = computed(() => isEditMode.value
? permissionCodes.customerFollowUpReminder.update
: permissionCodes.customerFollowUpReminder.create);
// ---------- 表单 ----------
const isSaving = ref(false);
const formModel = reactive({
id: "",
storeId: "",
customerId: "",
responsibleUserId: "",
content: "",
remindAtText: "",
notifyChannelList: [] as FollowUpReminderNotifyChannel[]
});
const selectedCustomer = ref<Customer>();
const selectedResponsible = ref<ManagedUser>();
const photoItems = ref<PhotoItem[]>([]);
const photoUrlList = computed(() => photoItems.value.map(item => item.previewUrl || item.url));
const customerSelectorVisible = ref(false);
const responsibleSelectorVisible = ref(false);
const customerPickerStoreId = computed(() => formModel.storeId || currentStoreStore.currentStoreId);
const responsiblePickerStoreId = computed(() => selectedCustomer.value?.storeId || customerPickerStoreId.value);
const currentUserId = computed(() => userStore.loginUser.user?.id || userStore.loginUser.id || "");
const selectedResponsibleName = computed(() => {
if (!selectedResponsible.value) {
return "请选择负责人";
}
if (selectedResponsible.value.userId === currentUserId.value) {
return "本人";
}
return getResponsibleName(selectedResponsible.value);
});
// ---------- 通知方式 ----------
const hasSmsChannel = computed(() => hasChannel("SMS"));
const isTodayReminder = computed(() => {
const remindAt = parseRemindAt();
if (!remindAt) {
return false;
}
return Time.formatDate(new Date(remindAt)) === Time.formatDate(new Date(Time.now()));
});
const isBeforeSmsCutoff = computed(() => {
const now = new Date(Time.now());
return now.getHours() < 8;
});
const canSelectSms = computed(() => isResponsiblePhoneValid(selectedResponsible.value)
&& (!isTodayReminder.value || isBeforeSmsCutoff.value));
const smsRuleText = computed(() => {
if (!selectedResponsible.value) {
return "短信通知要求负责人有有效的已验证手机号。";
}
if (!isResponsiblePhoneValid(selectedResponsible.value)) {
return "当前负责人没有有效的已验证手机号,短信通知不可用。";
}
if (isTodayReminder.value && !isBeforeSmsCutoff.value) {
return "今天 08:00 后创建今天的提醒不能选择短信通知。";
}
return "短信将在提醒日期 08:00 聚合发送,允许与站内通知同时选择。";
});
function hasChannel(channel: FollowUpReminderNotifyChannel): boolean {
return formModel.notifyChannelList.includes(channel);
}
function setChannel(channel: FollowUpReminderNotifyChannel, checked: boolean): void {
if (channel === "SMS" && checked && !canSelectSms.value) {
Message.warning(smsRuleText.value);
return;
}
const channelSet = new Set(formModel.notifyChannelList);
if (checked) {
channelSet.add(channel);
} else {
channelSet.delete(channel);
}
formModel.notifyChannelList = Array.from(channelSet);
}
function isResponsiblePhoneValid(item?: ManagedUser): boolean {
const phone = item?.user?.phoneNo;
return Boolean(phone && item?.user?.phoneNoVerified && /^1\d{10}$/.test(phone));
}
function getResponsibleName(item: ManagedUser): string {
return item.user?.nick || item.user?.name || item.userId || "未命名负责人";
}
// ---------- 客户和负责人 ----------
async function handleCustomerChange(customerId: string): Promise<void> {
formModel.customerId = customerId;
selectedCustomer.value = customerId ? await loadCustomer(customerId) : undefined;
formModel.storeId = selectedCustomer.value?.storeId || currentStoreStore.currentStoreId;
if (selectedResponsible.value && selectedResponsible.value.storeId !== formModel.storeId) {
formModel.responsibleUserId = "";
selectedResponsible.value = undefined;
setChannel("SMS", false);
}
}
function handleResponsibleChange(item: ManagedUser): void {
selectedResponsible.value = item;
formModel.responsibleUserId = item.userId || "";
if (isResponsiblePhoneValid(item) && canSelectSms.value) {
setChannel("SMS", true);
}
}
function selectCurrentUser(): void {
if (!currentUserId.value) {
Message.warning("当前登录用户信息不可用");
return;
}
handleResponsibleChange({
storeId: responsiblePickerStoreId.value,
userId: currentUserId.value,
enabled: true,
user: userStore.loginUser.user as Partial<User> | undefined
});
}
async function loadCustomer(customerId: string): Promise<Customer | undefined> {
try {
return await CustomerAPI.detail(customerId);
} catch {
Message.warning("客户详情加载失败");
return undefined;
}
}
// ---------- 时间 ----------
type RemindAtPreset = "tomorrow" | "afterTomorrow" | "custom";
const selectedRemindAtPreset = ref<RemindAtPreset>("tomorrow");
const remindAtPickerVisible = ref(false);
const remindAtDraftText = ref("");
const minimumDateTimeText = computed(() => {
const date = new Date(Time.now());
date.setSeconds(0, 0);
date.setMinutes(date.getMinutes() + 1);
return Time.formatDateTime(date);
});
function getQuickRemindAt(dayOffset: number): string {
const date = new Date(Time.now());
date.setSeconds(0, 0);
date.setDate(date.getDate() + dayOffset);
return Time.formatDateTime(date);
}
function getQuickRemindDate(dayOffset: number): string {
const date = new Date(Time.now());
date.setDate(date.getDate() + dayOffset);
return Time.formatDate(date);
}
function getRemindAtPreset(value: string): RemindAtPreset {
const remindAt = Time.toTimestamp(parseDateTime(value, false));
if (!remindAt) {
return "tomorrow";
}
const remindDate = Time.formatDate(new Date(remindAt));
if (remindDate === getQuickRemindDate(1)) {
return "tomorrow";
}
if (remindDate === getQuickRemindDate(2)) {
return "afterTomorrow";
}
return "custom";
}
function selectRemindAtPreset(preset: RemindAtPreset): void {
selectedRemindAtPreset.value = preset;
if (preset === "custom") {
openRemindAtPicker();
return;
}
formModel.remindAtText = getQuickRemindAt(preset === "tomorrow" ? 1 : 2);
}
function handleRemindAtPresetChange(value: string | number | boolean): void {
if (value !== "tomorrow" && value !== "afterTomorrow" && value !== "custom") {
return;
}
selectRemindAtPreset(value);
}
function openRemindAtPicker(): void {
remindAtDraftText.value = formModel.remindAtText || minimumDateTimeText.value;
remindAtPickerVisible.value = true;
}
function cancelRemindAtPicker(): void {
remindAtPickerVisible.value = false;
selectedRemindAtPreset.value = getRemindAtPreset(formModel.remindAtText);
}
function confirmRemindAt(value?: string | number): void {
formModel.remindAtText = String(value || remindAtDraftText.value);
selectedRemindAtPreset.value = "custom";
remindAtPickerVisible.value = false;
}
function parseRemindAt(): number | undefined {
if (!formModel.remindAtText) {
return undefined;
}
return Time.toTimestamp(parseDateTime(formModel.remindAtText, false));
}
// ---------- 附件 ----------
const isUploading = ref(false);
async function uploadReminderPhotoList(fileList: File[]): Promise<PhotoItem[]> {
isUploading.value = true;
try {
const tempFileList = await AttachmentAPI.upload(await compressUploadImageList(fileList));
return tempFileList.flatMap((item, index) => {
if (!item.id) {
return [];
}
return [
{
attachment: {
tempFileId: item.id,
mimeType: fileList[index]?.type,
name: fileList[index]?.name,
title: fileList[index] ? getFileTitle(fileList[index].name) : undefined
},
tempFileId: item.id,
attachType: "PHOTO",
mimeType: fileList[index]?.type,
url: AttachmentAPI.getTempReadURL(item.id || "")
}
];
});
} catch (error) {
Message.error(getErrorMessage(error, "附件上传失败"));
return [];
} finally {
isUploading.value = false;
}
}
function getFileTitle(name: string): string {
return name.replace(/\.[^.]+$/, "");
}
function getAttachmentURL(item: FollowUpReminderAttachment): string {
if (item.id) {
return getAttachmentReadURL(item.id);
}
if (item.tempFileId) {
return AttachmentAPI.getTempReadURL(item.tempFileId);
}
return "";
}
function toPhotoItems(items: readonly FollowUpReminderAttachment[]): PhotoItem[] {
const result: PhotoItem[] = [];
for (const item of items) {
const url = getAttachmentURL(item);
if (!url) {
continue;
}
result.push({
attachment: item,
tempFileId: item.tempFileId,
attachType: item.attachType || "PHOTO",
url
});
}
return result;
}
function toAttachmentList(items: readonly PhotoItem[]): FollowUpReminderAttachment[] {
return items.map(item => ({
...(item.attachment || {}),
tempFileId: item.tempFileId || item.attachment?.tempFileId,
attachType: item.attachType || item.attachment?.attachType || "PHOTO"
})).filter(item => !!item.id || !!item.tempFileId);
}
// ---------- 加载和保存 ----------
async function loadStoreScope(): Promise<void> {
if (userStore.hasRoles(gaoRoleCodes.globalManager)) {
await currentStoreStore.loadStoreList();
formModel.storeId = currentStoreStore.currentStoreId;
return;
}
const store = await currentStoreStore.loadLoginStore();
formModel.storeId = store?.id || "";
}
async function loadForm(): Promise<void> {
await loadStoreScope();
if (!routeId.value) {
formModel.notifyChannelList = ["INTERNAL"];
const queryCustomerId = String(route.query.customerId || "");
if (queryCustomerId) {
await handleCustomerChange(queryCustomerId);
}
selectCurrentUser();
formModel.remindAtText = getQuickRemindAt(1);
selectedRemindAtPreset.value = "tomorrow";
return;
}
const current = await FollowUpReminderAPI.detail(routeId.value);
if (current.status !== "PENDING") {
Message.warning("已完成的跟进提醒不能修改");
await router.replace({ name: "CustomerFollowUpReminderDetail", params: { id: routeId.value } });
return;
}
applyReminder(current);
}
function applyReminder(current: FollowUpReminder): void {
Object.assign(formModel, {
id: current.id || "",
storeId: current.storeId || "",
customerId: current.customerId || "",
responsibleUserId: current.responsibleUserId || "",
content: current.content || "",
remindAtText: current.remindAt ? Time.formatDateTime(new Date(current.remindAt)) : minimumDateTimeText.value,
notifyChannelList: current.notifyChannelList || buildChannelList(current)
});
selectedCustomer.value = current.customer;
selectedResponsible.value = current.responsibleUserId
? {
storeId: current.storeId,
userId: current.responsibleUserId,
enabled: true,
user: current.responsibleUser as Partial<User> | undefined
}
: undefined;
photoItems.value = toPhotoItems(current.attachmentList || []);
selectedRemindAtPreset.value = getRemindAtPreset(formModel.remindAtText);
}
function buildChannelList(item: FollowUpReminder): FollowUpReminderNotifyChannel[] {
const result: FollowUpReminderNotifyChannel[] = [];
if (item.internalNotifyEnabled) {
result.push("INTERNAL");
}
if (item.smsNotifyEnabled) {
result.push("SMS");
}
return result;
}
function validateForm(remindAt?: number): boolean {
if (!formModel.customerId || !selectedCustomer.value?.id) {
Message.warning("请选择客户");
return false;
}
if (!formModel.responsibleUserId || !selectedResponsible.value?.userId) {
Message.warning("请选择负责人");
return false;
}
if (!formModel.content.trim()) {
Message.warning("请填写跟进内容");
return false;
}
if (!remindAt || !(0 === remindAt % 60000) || !(Time.now() < remindAt)) {
Message.warning("请选择晚于当前时间的分钟提醒时间");
return false;
}
if (hasSmsChannel.value && !canSelectSms.value) {
Message.warning(smsRuleText.value);
return false;
}
return true;
}
async function save(): Promise<void> {
if (isSaving.value || isUploading.value) {
return;
}
const remindAt = parseRemindAt();
if (!validateForm(remindAt)) {
return;
}
const payload: FollowUpReminderPayload = {
id: formModel.id || undefined,
storeId: formModel.storeId || undefined,
customerId: formModel.customerId,
responsibleUserId: formModel.responsibleUserId,
content: formModel.content.trim(),
remindAt: remindAt as number,
notifyChannelList: [...formModel.notifyChannelList],
attachmentList: toAttachmentList(photoItems.value)
};
isSaving.value = true;
try {
let id = formModel.id;
if (isEditMode.value) {
await FollowUpReminderAPI.update(payload);
} else {
id = await FollowUpReminderAPI.create(payload);
}
Message.success("跟进提醒已保存");
await router.replace({ name: "CustomerFollowUpReminderDetail", params: { id } });
} catch (error) {
Message.error(getErrorMessage(error, "跟进提醒保存失败"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
navBarStore.setRightText(isEditMode.value ? "保存" : "提交", "var(--td-brand-color)", save);
try {
await loadForm();
} catch (error) {
Message.error(getErrorMessage(error, "跟进提醒加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.follow-up-reminder-edit {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 4rem;
flex-direction: column;
.form {
border-radius: 1rem;
.customer-picker {
gap: .5rem;
display: flex;
align-items: center;
}
.customer-profile {
flex: 1;
}
.responsible-picker {
gap: .5rem;
display: flex;
align-items: center;
}
.textarea {
width: 100%;
}
.remind-at {
&:after {
display: none;
}
}
.warning {
color: var(--td-error-color);
}
}
.tips {
margin: 0 2rem;
font-size: .875rem;
line-height: 1.5;
}
}
:global(html.theme-dark) {
.follow-up-reminder-edit {
.form {
.picker-text {
color: #e2e8f0;
}
.textarea {
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
}
}
}
</style>