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,869 @@
<template>
<div v-can="permissionCodes.customerFollowUpReminder.read" class="follow-up-reminder-list">
<div class="filter bg-white">
<t-input
v-model="customerSearchKeyword"
class="customer"
type="search"
borderless
placeholder="输入客户编码、姓名或拼音"
@enter="applyCustomerSearch"
@clear="clearCustomer"
>
<template #suffix-icon>
<t-button
size="extra-small"
theme="primary"
variant="outline"
content="选择客户"
@click.stop="customerSelectorVisible = true"
/>
</template>
</t-input>
<t-dropdown-menu ref="dropdownMenuRef" class="menus">
<t-dropdown-item
v-model="statusFilter"
:label="statusFilterLabel"
:options="statusOptions"
@change="handleStatusChange"
/>
<t-dropdown-item
v-model="dateFilterType"
:label="dateFilterLabel"
:options="dateOptions"
@change="handleDateFilterChange"
/>
<t-dropdown-item
:label="selectedResponsibleName"
:options="[]"
@open="openResponsibleSelector"
/>
</t-dropdown-menu>
</div>
<t-pull-down-refresh
class="list-wrap"
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="fetch"
>
<t-cell-group class="summary" theme="card">
<t-cell title="提醒数量" :note="`共 ${pageResult.total} 条`" />
<t-cell v-if="dateFilterType !== 'all'" title="提醒时间" :note="dateFilterLabel" />
<t-cell v-if="selectedCustomer || selectedResponsibleId" title="筛选条件">
<template #note>
<div class="selected-filter">
<t-tag
v-if="selectedCustomer"
theme="primary"
variant="light-outline"
:content="`客户:${selectedCustomer.name || selectedCustomer.code || '未命名'}`"
/>
<t-tag
v-if="selectedResponsibleId"
theme="primary"
variant="light-outline"
:content="`负责人:${selectedResponsibleName}`"
/>
<t-button size="extra-small" variant="text" @click="clearFilters">清空筛选</t-button>
</div>
</template>
</t-cell>
</t-cell-group>
<t-cell-group v-if="pageResult.list.length" class="items fix-last-cell-line empty-cell-title-margin" theme="card">
<t-swipe-cell
v-for="(item, index) in pageResult.list"
:key="item.id || index"
class="item"
>
<t-cell
v-entrance-transition="index * 30"
class="cell"
arrow
@click="toDetail(item)"
>
<template #leftIcon>
<t-image
v-if="getCustomerPhotoURL(item.customer, 64)"
:src="getCustomerPhotoURL(item.customer, 64)"
:alt="getCustomerName(item)"
fit="cover"
class="avatar"
/>
<div v-else class="avatar fallback" v-text="getCustomerName(item).slice(0, 1)" />
</template>
<template #title>
<div class="title">
<div class="name-channel">
<span class="text-bold" v-text="getCustomerName(item)" />
<t-tag
v-if="item.notifyChannelList?.includes('INTERNAL')"
size="small"
theme="primary"
variant="outline"
content="站内信"
/>
<t-tag
v-if="item.notifyChannelList?.includes('SMS')"
size="small"
theme="warning"
variant="outline"
content="短信"
/>
</div>
<t-tag size="small" :theme="getStatusTheme(item.status)" :content="getStatusText(item.status)" />
</div>
</template>
<template #description>
<div class="description">
<div class="content clip-text" v-text="getContentSummary(item.content)" />
<div class="time-responsible">
<span
class="remind-time"
:class="getReminderTimeClass(item)"
v-text="getReminderTimeText(item.remindAt)"
/>
<span v-text="`负责人:${getResponsibleName(item)}`" />
</div>
</div>
</template>
</t-cell>
<template #right>
<div class="swipe-actions">
<t-button
v-if="canComplete && item.status === 'PENDING'"
class="complete swipe-cell-button"
variant="text"
:loading="operatingId === item.id && operatingType === 'complete'"
@click.stop="confirmComplete(item)"
>完成</t-button>
<t-button
v-if="canDelete"
class="delete swipe-cell-button"
variant="text"
:loading="operatingId === item.id && operatingType === 'delete'"
@click.stop="confirmDelete(item)"
>删除</t-button>
</div>
</template>
</t-swipe-cell>
</t-cell-group>
<div class="center-tips" v-text="isLoading ? '正在加载提醒' : pageResult.list.length ? (isNoMore ? '已经到底了' : '上拉加载更多') : '暂无跟进提醒'" />
</t-pull-down-refresh>
<customer-selector
v-model="selectedCustomerId"
v-model:visible="customerSelectorVisible"
:initial-keyword="customerSearchKeyword"
:store-id="currentStoreStore.currentStoreId"
:enable-scan="true"
@change="handleCustomerSelected"
/>
<managed-user-selector
v-model="selectedResponsibleId"
v-model:visible="responsibleSelectorVisible"
:store-id="currentStoreStore.currentStoreId"
@change="handleResponsibleSelected"
/>
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
v-if="startVisible"
v-model="startDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="提醒开始时间"
@cancel="startVisible = false"
@confirm="confirmStart"
/>
</t-popup>
<t-popup v-model="endVisible" placement="bottom">
<t-date-time-picker
v-if="endVisible"
v-model="endDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="提醒结束时间"
@cancel="endVisible = false"
@confirm="confirmEnd"
/>
</t-popup>
</div>
<nav-bar-action-menu v-if="canCreate" :actions="actionList" />
</template>
<script setup lang="ts">
import { DialogPlugin, type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { type PageResult, useUserStore } from "timi-web-api";
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 { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer";
import type { ManagedUser } from "@/types/ManagedUser";
import type {
FollowUpReminder,
FollowUpReminderPage,
FollowUpReminderStatus
} from "@/types/FollowUpReminder";
import { getCustomerPhotoURL } from "@/utils/customer";
import { parseDateTime } from "@/utils/date";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type DateFilterType = "all" | "today" | "next7" | "custom";
defineOptions({
name: "FollowUpReminderList"
});
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const navBarStore = useNavBarStore();
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.customerFollowUpReminder.create));
const canComplete = computed(() => userStore.hasPermissions(permissionCodes.customerFollowUpReminder.complete));
const canDelete = computed(() => userStore.hasPermissions(permissionCodes.customerFollowUpReminder.delete));
// ---------- 筛选 ----------
const statusFilter = ref<"" | FollowUpReminderStatus>("");
const statusOptions = [
{ label: "全部状态", value: "" },
{ label: "待完成", value: "PENDING" },
{ label: "已完成", value: "COMPLETED" }
];
const statusFilterLabel = computed(() => statusOptions.find(item => item.value === statusFilter.value)?.label || "全部状态");
const dateFilterType = ref<DateFilterType>("all");
const dateRange = ref<[string, string]>(["", ""]);
const dateOptions = [
{ label: "全部时间", value: "all" },
{ label: "今天", value: "today" },
{ label: "未来 7 天", value: "next7" },
{ label: "自定义范围", value: "custom" }
];
const dateFilterLabel = computed(() => {
if (dateFilterType.value === "custom" && dateRange.value[0]) {
return `${dateRange.value[0]} 至 ${dateRange.value[1]}`;
}
return dateOptions.find(item => item.value === dateFilterType.value)?.label || "全部时间";
});
const selectedCustomerId = ref("");
const selectedCustomer = ref<Customer>();
const customerSearchKeyword = ref("");
const customerSelectorVisible = ref(false);
const selectedResponsibleId = ref("");
const selectedResponsible = ref<ManagedUser>();
const responsibleSelectorVisible = ref(false);
const dropdownMenuRef = ref<{ collapseMenu: () => void }>();
const selectedResponsibleName = computed(() => selectedResponsible.value
? getManagedUserName(selectedResponsible.value)
: "全部负责人");
const startVisible = ref(false);
const endVisible = ref(false);
const startDraft = ref("");
const endDraft = ref("");
const actionList = computed(() => [{
action: toCreate,
icon: "add",
key: "create",
text: "新增跟进提醒",
theme: "primary" as const
}]);
async function handleStatusChange(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
statusFilter.value = String(value) as "" | FollowUpReminderStatus;
await refresh();
}
async function handleDateFilterChange(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value === "custom") {
openStartDate();
return;
}
dateRange.value = buildPresetDateRange(dateFilterType.value);
await refresh();
}
function openStartDate(): void {
startDraft.value = dateRange.value[0] || `${Time.formatDate(new Date())} 00:00`;
startVisible.value = true;
}
function openEndDate(): void {
endDraft.value = dateRange.value[1] || `${Time.formatDate(new Date())} 23:59`;
endVisible.value = true;
}
function confirmStart(value?: string | number): void {
startVisible.value = false;
dateRange.value = [String(value || startDraft.value), dateRange.value[1]];
openEndDate();
}
async function confirmEnd(value?: string | number): Promise<void> {
endVisible.value = false;
dateRange.value = [dateRange.value[0], String(value || endDraft.value)];
if (dateRange.value[1] < dateRange.value[0]) {
dateRange.value = [dateRange.value[1], dateRange.value[0]];
}
await refresh();
}
function buildPresetDateRange(type: DateFilterType): [string, string] {
const begin = new Date();
begin.setHours(0, 0, 0, 0);
const end = new Date(begin);
if (type === "next7") {
end.setDate(end.getDate() + 6);
}
end.setHours(23, 59, 0, 0);
return type === "all" ? ["", ""] : [Time.formatDateTime(begin), Time.formatDateTime(end)];
}
async function handleCustomerSelected(id: string): Promise<void> {
selectedCustomerId.value = id;
selectedCustomer.value = id ? await loadCustomer(id) : undefined;
customerSearchKeyword.value = getCustomerSearchKeyword(selectedCustomer.value);
await refresh();
}
async function applyCustomerSearch(): Promise<void> {
const keyword = customerSearchKeyword.value.trim();
if (!keyword) {
await clearCustomer();
return;
}
if (keyword === getCustomerSearchKeyword(selectedCustomer.value)) {
return;
}
try {
const result = await CustomerAPI.list({
index: 0,
size: 20,
storeId: currentStoreStore.currentStoreId || undefined,
keyword
});
const customer = result.list?.[0];
if (!customer?.id) {
Message.warning("未找到匹配客户");
return;
}
if (1 < (result.total || 0)) {
Message.warning("匹配到多个客户,请点击选择客户");
customerSelectorVisible.value = true;
return;
}
selectedCustomerId.value = customer.id;
selectedCustomer.value = customer;
customerSearchKeyword.value = getCustomerSearchKeyword(customer);
await refresh();
} catch (error) {
Message.error(getErrorMessage(error, "客户信息加载失败"));
}
}
function getCustomerSearchKeyword(customer?: Customer): string {
return customer?.name?.trim() || customer?.code?.trim() || "";
}
async function clearCustomer(): Promise<void> {
selectedCustomerId.value = "";
selectedCustomer.value = undefined;
customerSearchKeyword.value = "";
await refresh();
}
async function handleResponsibleSelected(item: ManagedUser): Promise<void> {
selectedResponsible.value = item;
await refresh();
}
function openResponsibleSelector(): void {
dropdownMenuRef.value?.collapseMenu();
responsibleSelectorVisible.value = true;
}
async function clearFilters(): Promise<void> {
statusFilter.value = "";
dateFilterType.value = "all";
dateRange.value = ["", ""];
selectedCustomerId.value = "";
selectedCustomer.value = undefined;
customerSearchKeyword.value = "";
selectedResponsibleId.value = "";
selectedResponsible.value = undefined;
await refresh();
}
// ---------- 列表 ----------
const isRefreshing = ref(false);
const isLoading = ref(false);
const isNoMore = ref(false);
const pageResult = reactive<PageResult<FollowUpReminder>>({
total: 0,
list: []
});
const page = reactive<FollowUpReminderPage>({
index: 0,
size: 16
});
let fetchTask: Promise<void> | undefined;
async function fetch(): Promise<void> {
if (isNoMore.value) {
return;
}
if (fetchTask) {
await fetchTask;
return;
}
const task = (async () => {
isLoading.value = true;
try {
const [beginRemindAt, endRemindAt] = buildDateTimestampRange();
const result = await FollowUpReminderAPI.list({
...page,
storeId: currentStoreStore.currentStoreId || undefined,
customerId: selectedCustomerId.value || undefined,
responsibleUserId: selectedResponsibleId.value || undefined,
status: statusFilter.value || undefined,
beginRemindAt,
endRemindAt
});
const list = result.list || [];
pageResult.total = result.total || 0;
pageResult.list = pageResult.list.concat(list);
page.index += 1;
isNoMore.value = list.length < page.size || pageResult.list.length >= pageResult.total;
} finally {
isLoading.value = false;
}
})();
fetchTask = task;
try {
await task;
} finally {
if (fetchTask === task) {
fetchTask = undefined;
}
}
}
async function refresh(): Promise<void> {
if (fetchTask) {
await fetchTask;
}
isRefreshing.value = true;
try {
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = false;
await fetch();
} finally {
isRefreshing.value = false;
}
}
function buildDateTimestampRange(): [number | undefined, number | undefined] {
return [
dateRange.value[0] ? Time.toTimestamp(parseDateTime(dateRange.value[0], false)) : undefined,
dateRange.value[1] ? Time.toTimestamp(parseDateTime(dateRange.value[1], true)) : undefined
];
}
async function loadCustomer(id: string): Promise<Customer | undefined> {
try {
return await CustomerAPI.detail(id);
} catch {
return undefined;
}
}
async function loadStoreScope(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
return;
}
await currentStoreStore.loadLoginStore();
}
// ---------- 展示 ----------
function getCustomerName(item: FollowUpReminder): string {
return item.customer?.name || item.customer?.code || item.customerId || "未命名客户";
}
function getManagedUserName(item: ManagedUser): string {
return item.user?.nick || item.user?.name || item.userId || "未命名负责人";
}
function getResponsibleName(item: FollowUpReminder): string {
return item.responsibleUser?.nick || item.responsibleUser?.name || item.responsibleUserId || "未命名负责人";
}
function getReminderTimeText(remindAt?: number): string {
return remindAt ? Time.toDateTime(remindAt) : "-";
}
function getReminderTimeClass(item: FollowUpReminder): "near" | "soon" | "later" | "normal" {
if (item.status === "COMPLETED" || !item.remindAt) {
return "normal";
}
const distance = item.remindAt - Time.now();
if (distance < 24 * 60 * 60 * 1000) {
return "near";
}
if (distance < 3 * 24 * 60 * 60 * 1000) {
return "soon";
}
return "later";
}
function getContentSummary(content?: string): string {
const text = content?.trim() || "未填写跟进内容";
return 60 < text.length ? `${text.slice(0, 60)}...` : text;
}
function getStatusText(status?: FollowUpReminderStatus): string {
return status === "COMPLETED" ? "已完成" : "待完成";
}
function getStatusTheme(status?: FollowUpReminderStatus): "success" | "warning" {
return status === "COMPLETED" ? "success" : "warning";
}
// ---------- 操作 ----------
const operatingId = ref("");
const operatingType = ref<"complete" | "delete">();
function confirmComplete(item: FollowUpReminder): void {
if (!item.id || !canComplete.value || item.status !== "PENDING" || operatingId.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "完成跟进提醒",
content: "完成后不能重新打开,确定完成此跟进提醒吗?",
cancelBtn: "取消",
confirmBtn: "完成",
onCancel() {
dialog.destroy();
},
onConfirm: async () => {
dialog.destroy();
await complete(item.id || "");
}
});
}
async function complete(id: string): Promise<void> {
operatingId.value = id;
operatingType.value = "complete";
try {
await FollowUpReminderAPI.complete(id);
const item = pageResult.list.find(current => current.id === id);
if (item) {
item.status = "COMPLETED";
}
Message.success("跟进提醒已完成");
} catch (error) {
Message.error(getErrorMessage(error, "跟进提醒完成失败"));
} finally {
operatingId.value = "";
operatingType.value = undefined;
}
}
function confirmDelete(item: FollowUpReminder): void {
if (!item.id || !canDelete.value || operatingId.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "删除跟进提醒",
content: "删除后列表中将不再显示,确定删除这条跟进提醒吗?",
cancelBtn: "取消",
confirmBtn: "删除",
onCancel() {
dialog.destroy();
},
onConfirm: async () => {
dialog.destroy();
await remove(item.id || "");
}
});
}
async function remove(id: string): Promise<void> {
operatingId.value = id;
operatingType.value = "delete";
try {
await FollowUpReminderAPI.remove(id);
pageResult.list = pageResult.list.filter(item => item.id !== id);
pageResult.total = Math.max(0, pageResult.total - 1);
Message.success("跟进提醒已删除");
} catch (error) {
Message.error(getErrorMessage(error, "跟进提醒删除失败"));
} finally {
operatingId.value = "";
operatingType.value = undefined;
}
}
// ---------- 导航和生命周期 ----------
async function toCreate(): Promise<void> {
await router.push({
name: "CustomerFollowUpReminderCreate",
query: selectedCustomerId.value ? { customerId: selectedCustomerId.value } : undefined
});
}
async function toDetail(item: FollowUpReminder): Promise<void> {
if (item.id) {
await router.push({ name: "CustomerFollowUpReminderDetail", params: { id: item.id } });
}
}
onMounted(async () => {
navBarStore.setCustomRightVisible(canCreate.value);
try {
await loadStoreScope();
const queryCustomerId = String(route.query.customerId || "");
if (queryCustomerId) {
selectedCustomerId.value = queryCustomerId;
selectedCustomer.value = await loadCustomer(queryCustomerId);
customerSearchKeyword.value = getCustomerSearchKeyword(selectedCustomer.value);
}
await fetch();
} catch (error) {
Message.error(getErrorMessage(error, "跟进提醒列表加载失败"));
}
});
watch(() => route.query.customerId, async value => {
const id = String(value || "");
if (id === selectedCustomerId.value) {
return;
}
selectedCustomerId.value = id;
selectedCustomer.value = id ? await loadCustomer(id) : undefined;
customerSearchKeyword.value = getCustomerSearchKeyword(selectedCustomer.value);
await refresh();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.follow-up-reminder-list {
padding: var(--app-nav-height) 0 1rem;
.filter {
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;
.customer {
--td-button-border-radius: .75rem;
--td-input-vertical-padding: .25rem 1rem;
width: 100%;
border: 1px solid var(--td-component-border);
background: var(--td-bg-color-container);
padding-right: .3rem;
border-radius: 1rem;
}
.menus {
--td-spacer-1: 6px;
width: 100%;
border: 1px solid rgba(148, 163, 184, .18);
z-index: -1;
overflow: hidden;
margin-top: -1rem;
background: #fff;
border-top: none;
padding-top: .8rem;
border-radius: 0 0 1rem 1rem;
}
}
.selected-filter {
gap: .5rem;
width: 100%;
min-width: 0;
display: flex;
overflow: auto;
align-items: center;
:deep(.t-tag) {
flex: 0 0 auto;
}
.t-button {
flex: 0 0 auto;
}
}
.list-wrap {
padding: 1rem 0;
.summary {
margin-bottom: 1rem;
}
.items {
.item {
.cell {
padding: 10px;
min-width: 0;
:deep(.t-cell__title),
:deep(.t-cell__title-text) {
min-width: 0;
overflow: hidden;
}
.avatar {
width: 3.5rem;
height: 3.5rem;
}
.title {
gap: .5rem;
width: 100%;
display: flex;
min-width: 0;
font-size: .875rem;
align-items: center;
justify-content: space-between;
.name-channel {
gap: .5rem;
display: flex;
min-width: 0;
overflow: hidden;
align-items: center;
> .text-bold {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.description {
font-size: .75rem;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
line-height: 1.25;
.remind-time {
font-weight: 600;
&.near {
color: var(--td-error-color);
}
&.soon {
color: var(--td-warning-color);
}
&.later {
color: var(--td-success-color);
}
}
.content {
color: var(--td-text-color-secondary);
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-responsible {
display: flex;
justify-content: space-between;
}
}
}
}
.swipe-actions {
--td-button-border-radius: 0;
.complete {
color: #fff;
background: var(--td-success-color);
}
.delete {
color: #fff;
background: var(--td-error-color);
}
}
}
}
}
:global(html.theme-dark) {
.follow-up-reminder-list {
.filter {
background: #10161f;
}
.filter .menus {
background: #172033;
}
.summary,
.items .item,
.items .cell {
color: #e2e8f0;
background: rgba(30, 41, 59, .9);
border-color: rgba(148, 163, 184, .16);
}
.description .content {
color: #94a3b8;
}
}
}
</style>