From 57c4275a2378991d09ec9780ae78c54037ea58f5 Mon Sep 17 00:00:00 2001 From: Timi Date: Fri, 18 Sep 2026 17:38:55 +0800 Subject: [PATCH] update AIChart, customer followup, media selector --- src/api/FollowUpReminderAPI.ts | 44 + src/assets/styles/global.less | 21 +- src/components/CustomerSelector.vue | 3 + src/components/EventRecordVerifyDialog.vue | 40 +- src/components/PhotoScroller.vue | 569 ------------ .../mediaSelector/MediaSelector.vue | 276 ++++++ .../mediaSelector/MediaSelectorAdd.vue | 204 ++++ .../mediaSelector/MediaSelectorItem.vue | 145 +++ .../mediaSelector/MediaSelectorViewer.vue | 235 +++++ src/components/mediaSelector/types.ts | 13 + src/components/mediaSelector/utils.ts | 55 ++ src/composables/useAiChat.ts | 696 -------------- src/pages/ai/components/AiChatComposer.vue | 135 --- src/pages/ai/components/AiChatMessageList.vue | 402 -------- src/pages/ai/components/AiChatPanel.vue | 390 ++++++-- src/pages/ai/components/AiChatThinking.vue | 84 -- .../ai/components/AiConversationList.vue | 2 +- src/pages/customer/CustomerDetail.vue | 22 +- src/pages/customer/CustomerEdit.vue | 66 +- src/pages/event/EventRecordDetail.vue | 4 +- src/pages/event/EventRecordEdit.vue | 30 +- .../event/component/useEventRecordPhoto.ts | 18 +- .../FollowUpReminderDetail.vue | 377 ++++++++ .../followUpReminder/FollowUpReminderEdit.vue | 645 +++++++++++++ .../followUpReminder/FollowUpReminderList.vue | 869 ++++++++++++++++++ .../component/ManagedUserSelector.vue | 221 +++++ .../fulfillment/OrderFulfillmentCreate.vue | 4 +- .../fulfillment/OrderFulfillmentDetail.vue | 6 +- src/pages/home/QuickSignInPage.vue | 39 +- src/pages/package/PackagePurchase.vue | 8 +- src/pages/point/PointRedemptionCreate.vue | 4 +- src/pages/point/PointRedemptionDetail.vue | 6 +- src/pages/product/ProductDetail.vue | 8 +- src/pages/product/ProductEdit.vue | 27 +- .../product/ProductStockOperationPopup.vue | 4 +- .../product/ProductStockRecordDetail.vue | 6 +- .../product/component/ProductSkuEditor.vue | 4 +- src/pages/salesOrder/FundRecordDetail.vue | 4 +- src/pages/salesOrder/FundRecordRevoke.vue | 4 +- src/pages/salesOrder/SalesOrderCreate.vue | 8 +- src/pages/salesOrder/SalesOrderDetail.vue | 4 +- src/pages/salesOrder/SalesOrderPayment.vue | 4 +- src/pages/salesOrder/SalesOrderRefund.vue | 4 +- src/pages/store/StoreDiaryDetail.vue | 4 +- src/pages/store/StoreDiaryEdit.vue | 38 +- .../store/component/StoreCustomerAgeChart.vue | 2 +- src/pages/tabs/ProductTab.vue | 2 +- src/pages/tabs/SettingTab.vue | 6 + src/router/index.ts | 2 + src/router/routes/followUpReminderRoutes.ts | 62 ++ src/types/Customer.ts | 8 +- src/types/FollowUpReminder.ts | 60 ++ src/types/Photo.ts | 5 +- src/utils/imageCompression.ts | 4 + src/utils/permission.ts | 12 + src/utils/photo.ts | 18 +- tsconfig.app.json | 4 +- 57 files changed, 3866 insertions(+), 2071 deletions(-) create mode 100644 src/api/FollowUpReminderAPI.ts delete mode 100644 src/components/PhotoScroller.vue create mode 100644 src/components/mediaSelector/MediaSelector.vue create mode 100644 src/components/mediaSelector/MediaSelectorAdd.vue create mode 100644 src/components/mediaSelector/MediaSelectorItem.vue create mode 100644 src/components/mediaSelector/MediaSelectorViewer.vue create mode 100644 src/components/mediaSelector/types.ts create mode 100644 src/components/mediaSelector/utils.ts delete mode 100644 src/composables/useAiChat.ts delete mode 100644 src/pages/ai/components/AiChatComposer.vue delete mode 100644 src/pages/ai/components/AiChatMessageList.vue delete mode 100644 src/pages/ai/components/AiChatThinking.vue create mode 100644 src/pages/followUpReminder/FollowUpReminderDetail.vue create mode 100644 src/pages/followUpReminder/FollowUpReminderEdit.vue create mode 100644 src/pages/followUpReminder/FollowUpReminderList.vue create mode 100644 src/pages/followUpReminder/component/ManagedUserSelector.vue create mode 100644 src/router/routes/followUpReminderRoutes.ts create mode 100644 src/types/FollowUpReminder.ts diff --git a/src/api/FollowUpReminderAPI.ts b/src/api/FollowUpReminderAPI.ts new file mode 100644 index 0000000..84929da --- /dev/null +++ b/src/api/FollowUpReminderAPI.ts @@ -0,0 +1,44 @@ +import { axios } from "timi-web-api"; +import type { PageResult } from "timi-web-api"; +import type { + FollowUpReminder, + FollowUpReminderPage, + FollowUpReminderPayload +} from "@/types/FollowUpReminder"; + +const BASE_URI = "/gao/customer/follow-up-reminder"; + +/** 客户跟进提醒接口。 */ +const FollowUpReminderAPI = { + async list(page: FollowUpReminderPage): Promise> { + return await axios.post(`${BASE_URI}/list`, page); + }, + + async detail(id: string): Promise { + return await axios.post(`${BASE_URI}/detail`, undefined, { + params: { id } + }); + }, + + async create(payload: FollowUpReminderPayload): Promise { + return await axios.post(`${BASE_URI}/create`, payload); + }, + + async update(payload: FollowUpReminderPayload): Promise { + return await axios.post(`${BASE_URI}/update`, payload); + }, + + async complete(id: string): Promise { + return await axios.post(`${BASE_URI}/complete`, undefined, { + params: { id } + }); + }, + + async remove(id: string): Promise { + return await axios.post(`${BASE_URI}/delete`, undefined, { + params: { id } + }); + } +}; + +export default FollowUpReminderAPI; diff --git a/src/assets/styles/global.less b/src/assets/styles/global.less index 45f6594..aaea06d 100644 --- a/src/assets/styles/global.less +++ b/src/assets/styles/global.less @@ -79,12 +79,19 @@ html.theme-dark body { text-overflow: unset; } -.t-cell__title { +.t-cell { - .t-cell__title-text { - min-width: unset; - white-space: nowrap; - text-overflow: unset; + .t-cell__title { + + .t-cell__title-text { + min-width: unset; + white-space: nowrap; + text-overflow: unset; + } + } + + .t-cell__description { + margin-top: 0; } } @@ -128,10 +135,10 @@ html.theme-dark body { border-radius: .875rem !important; &.fallback { - color: #2563EB; + color: #FFF; display: flex; font-size: 1.25rem; - background: #DBEAFE; + background: var(--td-brand-color); align-items: center; font-weight: 600; justify-content: center; diff --git a/src/components/CustomerSelector.vue b/src/components/CustomerSelector.vue index 45f76a6..b91717b 100644 --- a/src/components/CustomerSelector.vue +++ b/src/components/CustomerSelector.vue @@ -70,6 +70,7 @@ type SelectionMode = "single" | "multiple"; interface Props { enableScan?: boolean; + initialKeyword?: string; modelValue: string | string[]; visible: boolean; selectionMode?: SelectionMode; @@ -78,6 +79,7 @@ interface Props { const props = withDefaults(defineProps(), { enableScan: true, + initialKeyword: "", selectionMode: "single", storeId: "" }); @@ -289,6 +291,7 @@ watch(() => props.visible, async (value) => { return; } syncDraftSelection(); + keyword.value = props.initialKeyword; await reloadCustomers(); }); diff --git a/src/components/EventRecordVerifyDialog.vue b/src/components/EventRecordVerifyDialog.vue index 9d633d4..0776a92 100644 --- a/src/components/EventRecordVerifyDialog.vue +++ b/src/components/EventRecordVerifyDialog.vue @@ -10,7 +10,7 @@
-
@@ -27,15 +29,31 @@ ref="photoInputRef" class="file-input" type="file" - accept="image/*" + accept="*/*" multiple @change="handlePhotoSelected" > + + - - diff --git a/src/components/mediaSelector/MediaSelector.vue b/src/components/mediaSelector/MediaSelector.vue new file mode 100644 index 0000000..dbaeb8d --- /dev/null +++ b/src/components/mediaSelector/MediaSelector.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/src/components/mediaSelector/MediaSelectorAdd.vue b/src/components/mediaSelector/MediaSelectorAdd.vue new file mode 100644 index 0000000..7d1e0c7 --- /dev/null +++ b/src/components/mediaSelector/MediaSelectorAdd.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/src/components/mediaSelector/MediaSelectorItem.vue b/src/components/mediaSelector/MediaSelectorItem.vue new file mode 100644 index 0000000..1f0a6ed --- /dev/null +++ b/src/components/mediaSelector/MediaSelectorItem.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/src/components/mediaSelector/MediaSelectorViewer.vue b/src/components/mediaSelector/MediaSelectorViewer.vue new file mode 100644 index 0000000..833dc89 --- /dev/null +++ b/src/components/mediaSelector/MediaSelectorViewer.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/src/components/mediaSelector/types.ts b/src/components/mediaSelector/types.ts new file mode 100644 index 0000000..93d8fa4 --- /dev/null +++ b/src/components/mediaSelector/types.ts @@ -0,0 +1,13 @@ +import type { ButtonProps } from "tdesign-mobile-vue"; +import type { PhotoItem } from "@/types/Photo.ts"; + +export type MediaSelectorAddAction = { + icon?: string; + emit: string; + text: string; + theme?: ButtonProps["theme"]; +}; + +export type MediaSelectorViewerItem = PhotoItem & { + sourceIndex: number; +}; diff --git a/src/components/mediaSelector/utils.ts b/src/components/mediaSelector/utils.ts new file mode 100644 index 0000000..184491d --- /dev/null +++ b/src/components/mediaSelector/utils.ts @@ -0,0 +1,55 @@ +import type { PhotoItem } from "@/types/Photo.ts"; +import { getAttachmentReadURL } from "@/utils/attachment.ts"; + +const ATTACH_TYPE_TEXT_MAP: Record = { + ADDITIONAL: "附加照片", + PHOTO: "照片", + POINT_CARD: "积分卡", + OTHER: "其他文件" +}; + +/** 缩略图宽度查询串,地址中出现即表示已带缩略图参数 */ +const THUMBNAIL_WIDTH_QUERY = "thumbWidth="; + +/** 缩略图兜底尺寸(像素),用于地址未带缩略图参数但可定位附件的场景 */ +const FALLBACK_THUMBNAIL_SIZE = 128; + +export function getAttachTypeText(attachType?: string): string { + return ATTACH_TYPE_TEXT_MAP[attachType || "PHOTO"] || "其他文件"; +} + +export function getMediaPreviewURL(item: PhotoItem): string { + return item.previewUrl || item.url; +} + +/** + * 解析媒体缩略图地址。 + * + * 后端读附件接口只要带缩略图参数,视频附件同样返回该视频的缩略帧, + * 因此视频在列表中统一按图片渲染;临时上传文件没有缩略图,返回空字符串由调用方降级。 + */ +export function getMediaThumbnailURL(item: PhotoItem): string { + if (item.url.includes(THUMBNAIL_WIDTH_QUERY)) { + return item.url; + } + + const attachmentId = item.attachment?.id; + return attachmentId ? getAttachmentReadURL(attachmentId, FALLBACK_THUMBNAIL_SIZE) : ""; +} + +export function isVideoItem(item: PhotoItem): boolean { + const mimeType = (item.mimeType || item.attachment?.mimeType || "").toLowerCase(); + if (mimeType.startsWith("video/")) { + return true; + } + + return /\.(avi|m4v|mkv|mov|mp4|mpeg|mpg|ogv|webm|wmv)(?:$|[?#])/i.test(item.url); +} + +export function normalizePhotoItem(item: PhotoItem): PhotoItem { + return { + ...item, + attachType: item.attachType || item.attachment?.attachType || "PHOTO", + mimeType: item.mimeType || item.attachment?.mimeType + }; +} diff --git a/src/composables/useAiChat.ts b/src/composables/useAiChat.ts deleted file mode 100644 index 5d70382..0000000 --- a/src/composables/useAiChat.ts +++ /dev/null @@ -1,696 +0,0 @@ -import { computed, onUnmounted, shallowRef, toValue, watch, type MaybeRefOrGetter } from "vue"; -import { useUserStore } from "timi-web-api/store"; -import { - chat, - ensureChatResponse, - GaoAiHttpError, - GaoAiStreamError, - parseSseData, - readConversation as readConversationApi, - readSseStream, - type GaoAiConversationMessage, - type GaoAiConversationReadResponse, - type GaoAiStreamEvent -} from "@/api/GaoAiAPI"; - -export type AiMessageRole = "user" | "assistant"; - -export interface AiCitation { - source?: string; - dataDomain?: string; - timeRange?: string; - caliber?: string; - generatedAt?: string; -} - -export interface AiThinking { - id: number; - content: string; -} - -export interface AiChatMessage { - id: number; - role: AiMessageRole; - content: string; - thinkingList: AiThinking[]; - citationList: AiCitation[]; - createdAt: number; - statusText?: string; - duration?: number; -} - -export interface UseAiChatOptions { - sessionKey: MaybeRefOrGetter; - storeId?: MaybeRefOrGetter; - scrollToBottom?: () => Promise; -} - -const conversationExpiredText = "AI 会话不存在或已过期"; -const thinkOpenMarkers = ["", ""]; -const thinkCloseMarkers = ["", ""]; - -interface AssistantResponseState { - rawText: string; - eventThinkingList: AiThinking[]; - taggedThinkingList: AiThinking[]; -} - -/** 管理门店智能助手的会话、流式消息和取消操作。 */ -export function useAiChat(options: UseAiChatOptions) { - const userStore = useUserStore(); - const messages = shallowRef([]); - const conversationId = shallowRef(""); - const isGenerating = shallowRef(false); - const isLoadingConversation = shallowRef(false); - const activeToolStatus = shallowRef(""); - const lastError = shallowRef(""); - const lastQuestion = shallowRef(""); - const canRetry = computed(() => !!lastError.value && !!lastQuestion.value && !isGenerating.value && !isLoadingConversation.value); - let activeController: AbortController | undefined; - let messageSequence = 0; - const responseStateMap = new Map(); - - async function sendMessage(message: string): Promise { - const normalizedMessage = message.trim(); - if (!normalizedMessage || isGenerating.value || isLoadingConversation.value) { - return; - } - - const userMessage: AiChatMessage = { - id: nextMessageId(), - role: "user", - content: normalizedMessage, - thinkingList: [], - citationList: [], - createdAt: Date.now() - }; - const assistantMessage: AiChatMessage = { - id: nextMessageId(), - role: "assistant", - content: "", - thinkingList: [], - citationList: [], - createdAt: Date.now() - }; - messages.value = [...messages.value, userMessage, assistantMessage]; - lastQuestion.value = normalizedMessage; - lastError.value = ""; - isGenerating.value = true; - activeToolStatus.value = ""; - const controller = new AbortController(); - activeController = controller; - let hasRetriedExpiredConversation = false; - - try { - while (true) { - resetAssistantMessage(assistantMessage.id); - try { - await readChatStream(normalizedMessage, assistantMessage.id, controller); - break; - } catch (error) { - if ( - !hasRetriedExpiredConversation - && !!conversationId.value - && isExpiredConversationError(error) - ) { - hasRetriedExpiredConversation = true; - conversationId.value = ""; - continue; - } - handleChatError(error, assistantMessage.id, controller.signal.aborted); - break; - } - } - } finally { - if (activeController === controller) { - activeController = undefined; - } - isGenerating.value = false; - activeToolStatus.value = ""; - responseStateMap.delete(assistantMessage.id); - await scrollToBottom(); - } - } - - function stopGeneration(): void { - if (!isGenerating.value) { - return; - } - activeController?.abort(); - activeToolStatus.value = ""; - const assistantMessage = [...messages.value].reverse().find(item => item.role === "assistant"); - if (assistantMessage) { - updateMessage(assistantMessage.id, { statusText: "已停止生成,可重新提问" }); - } - } - - function resetConversation(): void { - activeController?.abort(); - activeController = undefined; - messages.value = []; - responseStateMap.clear(); - conversationId.value = ""; - isGenerating.value = false; - activeToolStatus.value = ""; - lastError.value = ""; - lastQuestion.value = ""; - } - - /** - * 读取历史会话并转换成当前聊天页面使用的消息模型。 - * - * @param id 历史会话 ID - */ - async function loadConversation(id: string): Promise { - const normalizedId = id.trim(); - if (!normalizedId || isGenerating.value || isLoadingConversation.value) { - return; - } - - isLoadingConversation.value = true; - messages.value = []; - responseStateMap.clear(); - conversationId.value = normalizedId; - lastError.value = ""; - lastQuestion.value = ""; - try { - const result = await readConversationHistory(normalizedId); - if (conversationId.value !== normalizedId) { - return; - } - messages.value = mapConversationMessages(result); - lastQuestion.value = [...messages.value].reverse().find(item => item.role === "user")?.content || ""; - await scrollToBottom(); - } catch (error) { - if (conversationId.value === normalizedId) { - resetConversation(); - } - throw error; - } finally { - isLoadingConversation.value = false; - } - } - - async function readConversationHistory(id: string): Promise { - const messageList: GaoAiConversationMessage[] = []; - let firstId: string | undefined; - let result: GaoAiConversationReadResponse = {}; - - while (true) { - result = await readConversationApi({ - conversationId: id, - firstId, - limit: 100 - }); - const currentList = Array.isArray(result.data) ? result.data : []; - messageList.push(...currentList); - if (result["has_more"] !== true || !currentList.length) { - break; - } - const nextFirstId = currentList[0]?.id; - if (!nextFirstId || nextFirstId === firstId) { - break; - } - firstId = nextFirstId; - } - - return { - ...result, - data: messageList - }; - } - - async function retryLastMessage(): Promise { - if (canRetry.value) { - await sendMessage(lastQuestion.value); - } - } - - async function readChatStream(message: string, assistantId: number, controller: AbortController): Promise { - const storeId = options.storeId ? toValue(options.storeId) : undefined; - const request = { - message, - ...(conversationId.value ? { conversationId: conversationId.value } : {}), - ...(storeId ? { storeId } : {}) - }; - const response = await chat(request, userStore.token, controller.signal); - await ensureChatResponse(response); - let hasDoneEvent = false; - await readSseStream(response, async (event) => { - hasDoneEvent = await handleStreamEvent(event, assistantId, hasDoneEvent); - await options.scrollToBottom?.(); - }); - if (!hasDoneEvent && !controller.signal.aborted) { - throw new GaoAiStreamError("智能助手连接中断,请重试", "DIFY_STREAM_ERROR"); - } - } - - async function handleStreamEvent(event: GaoAiStreamEvent, assistantId: number, hasDoneEvent: boolean): Promise { - const payload = parseSseData(event.data); - switch (event.event) { - case "think": - case "thinking": - appendAssistantThinking(assistantId, extractThinkingText(payload)); - return hasDoneEvent; - case "agent_thought": - appendAssistantThinking(assistantId, extractThinkingText(payload), true); - return hasDoneEvent; - case "message": - appendAssistantResponse(assistantId, extractText(payload)); - return hasDoneEvent; - case "tool_status": - { - activeToolStatus.value = formatToolStatus(payload); - } - return hasDoneEvent; - case "citation": - appendCitation(assistantId, normalizeCitation(payload)); - return hasDoneEvent; - case "done": - applyDonePayload(assistantId, payload); - return true; - case "error": - throw createStreamError(payload); - default: - return hasDoneEvent; - } - } - - function applyDonePayload(assistantId: number, payload: unknown): void { - if (!isRecord(payload)) { - return; - } - const nextConversationId = getString(payload.conversationId); - if (nextConversationId) { - conversationId.value = nextConversationId; - } - const duration = getNumber(payload.duration); - updateMessage(assistantId, { - createdAt: Date.now(), - duration: duration === undefined ? undefined : duration - }); - } - - function appendAssistantResponse(assistantId: number, text: string): void { - if (!text) { - return; - } - const state = getAssistantResponseState(assistantId); - state.rawText = mergeResponseText(state.rawText, text); - const parsedResponse = parseAssistantResponse(state.rawText); - state.taggedThinkingList = parsedResponse.thinkingList.map((content, index) => ({ - id: state.taggedThinkingList[index]?.id || nextThinkingId(), - content - })); - updateMessage(assistantId, { - content: parsedResponse.answer, - thinkingList: [...state.eventThinkingList, ...state.taggedThinkingList], - statusText: undefined - }); - } - - function getAssistantResponseState(assistantId: number): AssistantResponseState { - const currentState = responseStateMap.get(assistantId); - if (currentState) { - return currentState; - } - const nextState: AssistantResponseState = { - rawText: "", - eventThinkingList: [], - taggedThinkingList: [] - }; - responseStateMap.set(assistantId, nextState); - return nextState; - } - - function appendAssistantThinking(assistantId: number, text: string, createSegment = false): void { - if (!text) { - return; - } - const state = getAssistantResponseState(assistantId); - const thinkingList = [...state.eventThinkingList]; - const lastThinking = thinkingList[thinkingList.length - 1]; - if (createSegment || !lastThinking) { - thinkingList.push({ id: nextThinkingId(), content: text }); - } else { - thinkingList[thinkingList.length - 1] = { - ...lastThinking, - content: lastThinking.content + text - }; - } - state.eventThinkingList = thinkingList; - updateMessage(assistantId, { - thinkingList: [...state.eventThinkingList, ...state.taggedThinkingList], - statusText: undefined - }); - } - - function appendCitation(assistantId: number, citation?: AiCitation): void { - if (!citation) { - return; - } - const message = messages.value.find(item => item.id === assistantId); - if (!message) { - return; - } - updateMessage(assistantId, { citationList: [...message.citationList, citation] }); - } - - function updateMessage(id: number, patch: Partial): void { - messages.value = messages.value.map(item => item.id === id ? { ...item, ...patch } : item); - } - - function resetAssistantMessage(id: number): void { - responseStateMap.delete(id); - updateMessage(id, { - content: "", - thinkingList: [], - citationList: [], - statusText: undefined, - duration: undefined, - createdAt: Date.now() - }); - } - - function handleChatError(error: unknown, assistantId: number, isAborted: boolean): void { - if (isAborted) { - return; - } - const message = getChatErrorMessage(error); - lastError.value = message; - updateMessage(assistantId, { statusText: message }); - } - - function clearMessages(): void { - messages.value = []; - responseStateMap.clear(); - } - - function nextMessageId(): number { - messageSequence += 1; - return messageSequence; - } - - let thinkingSequence = 0; - - function nextThinkingId(): number { - thinkingSequence += 1; - return thinkingSequence; - } - - function mapConversationMessages(response: GaoAiConversationReadResponse): AiChatMessage[] { - const sourceList = Array.isArray(response.data) ? response.data : []; - const orderedList = sourceList - .map((item, index) => ({ - item, - index, - timestamp: getTimestamp(item["created_at"]) - })) - .sort((left, right) => left.timestamp - right.timestamp || left.index - right.index) - .map(item => item.item); - const messageList: AiChatMessage[] = []; - - for (const item of orderedList) { - const createdAt = getTimestamp(item["created_at"]); - const query = getRawString(item.query); - const answer = getRawString(item.answer); - if (query) { - messageList.push({ - id: nextMessageId(), - role: "user", - content: query, - thinkingList: [], - citationList: [], - createdAt - }); - } - if (answer || isFailedConversationMessage(item)) { - const parsedAnswer = parseAssistantResponse(answer); - messageList.push({ - id: nextMessageId(), - role: "assistant", - content: parsedAnswer.answer, - thinkingList: parsedAnswer.thinkingList.map(content => ({ - id: nextThinkingId(), - content - })), - citationList: [], - createdAt, - statusText: isFailedConversationMessage(item) ? "本次回答生成失败" : undefined - }); - } - } - return messageList; - } - - async function scrollToBottom(): Promise { - await options.scrollToBottom?.(); - } - - watch(() => toValue(options.sessionKey), () => { - resetConversation(); - }, { flush: "sync" }); - - onUnmounted(() => { - activeController?.abort(); - clearMessages(); - }); - - return { - messages, - conversationId: computed(() => conversationId.value), - isGenerating, - isLoadingConversation, - activeToolStatus, - lastError, - canRetry, - sendMessage, - loadConversation, - stopGeneration, - resetConversation, - retryLastMessage - }; -} - -function isExpiredConversationError(error: unknown): boolean { - if (error instanceof GaoAiHttpError || error instanceof GaoAiStreamError) { - return error.message.includes(conversationExpiredText) - || error.code?.toLowerCase().includes("session") === true; - } - return false; -} - -function extractText(payload: unknown): string { - if (typeof payload === "string") { - return payload === "[DONE]" ? "" : payload; - } - if (!isRecord(payload)) { - return ""; - } - return getRawString(payload.answer) || getRawString(payload.text) || getRawString(payload.content) || ""; -} - -function mergeResponseText(currentText: string, nextText: string): string { - if (currentText && nextText.startsWith(currentText)) { - return nextText; - } - return currentText + nextText; -} - -function parseAssistantResponse(value: string): { answer: string; thinkingList: string[] } { - let answer = ""; - let thinking = ""; - let mode: "answer" | "thinking" = "answer"; - let cursor = 0; - const thinkingList: string[] = []; - - while (cursor < value.length) { - const marker = findMarker(value.slice(cursor), [...thinkOpenMarkers, ...thinkCloseMarkers]); - if (!marker) { - const rest = value.slice(cursor); - if (mode === "thinking") { - thinking += rest; - } else { - answer += rest; - } - break; - } - - const content = value.slice(cursor, cursor + marker.index); - if (mode === "thinking") { - thinking += content; - if (thinking) { - thinkingList.push(thinking); - } - thinking = ""; - mode = "answer"; - } else if (isThinkOpenMarker(marker.value)) { - answer += content; - mode = "thinking"; - } else { - if (content) { - thinkingList.push(content); - } - mode = "answer"; - } - cursor += marker.index + marker.value.length; - } - - if (mode === "thinking" && thinking) { - thinkingList.push(thinking); - } - - return { answer, thinkingList }; -} - -function findMarker(value: string, markerList: string[]): { index: number; value: string } | undefined { - const lowerValue = value.toLowerCase(); - return markerList.reduce<{ index: number; value: string } | undefined>((current, marker) => { - const index = lowerValue.indexOf(marker.toLowerCase()); - if (index < 0 || (current && current.index <= index)) { - return current; - } - return { index, value: marker }; - }, undefined); -} - -function isThinkOpenMarker(value: string): boolean { - return thinkOpenMarkers.some(marker => marker.toLowerCase() === value.toLowerCase()); -} - -function extractThinkingText(payload: unknown): string { - if (typeof payload === "string") { - return payload === "[DONE]" ? "" : payload; - } - if (!isRecord(payload)) { - return ""; - } - return getRawString(payload.thought) - || getRawString(payload.thinking) - || getRawString(payload.reasoning) - || getRawString(payload.observation) - || getRawString(payload.text) - || getRawString(payload.content) - || ""; -} - -function formatToolStatus(payload: unknown): string { - if (!isRecord(payload)) { - return "正在查询实时业务数据…"; - } - const status = getString(payload.status); - return status === "finished" ? "实时业务数据查询完成" : "正在查询实时业务数据…"; -} - -function normalizeCitation(payload: unknown): AiCitation | undefined { - if (typeof payload === "string") { - return payload ? { source: payload } : undefined; - } - if (!isRecord(payload)) { - return undefined; - } - const citation: AiCitation = { - source: getString(payload.source), - dataDomain: getString(payload.dataDomain) || getString(payload.domain), - timeRange: getString(payload.timeRange) || formatScope(payload.scope), - caliber: getString(payload.caliber) || getString(payload.metric), - generatedAt: getDisplayText(payload.generatedAt) || getDisplayText(payload.generated_at) - }; - return Object.values(citation).some(Boolean) ? citation : undefined; -} - -function createStreamError(payload: unknown): GaoAiStreamError { - if (!isRecord(payload)) { - return new GaoAiStreamError("智能助手查询失败,请稍后重试", "DIFY_ERROR"); - } - return new GaoAiStreamError( - getString(payload.message) || "智能助手查询失败,请稍后重试", - getString(payload.code) - ); -} - -function getChatErrorMessage(error: unknown): string { - if (error instanceof GaoAiHttpError) { - if (error.status === 401) { - return error.message.includes("会话") ? error.message : "登录已过期,请重新登录"; - } - if (error.status === 403) { - return "当前账号无权使用门店智能助手"; - } - if (error.status === 429) { - return "请求过于频繁,请稍后再试"; - } - return error.message; - } - if (error instanceof GaoAiStreamError) { - if (error.code === "DIFY_TIMEOUT") { - return "数据查询超时,请缩小时间范围后重试"; - } - return error.message; - } - if (error instanceof DOMException && error.name === "AbortError") { - return "已停止生成,可重新提问"; - } - if (error instanceof TypeError) { - return "连接中断,可重新提问"; - } - return "智能助手暂时不可用,请稍后重试"; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function getString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function getRawString(value: unknown): string { - return typeof value === "string" ? value : ""; -} - -function getDisplayText(value: unknown): string | undefined { - const text = getString(value); - if (text) { - return text; - } - const timestamp = getNumber(value); - if (timestamp === undefined) { - return undefined; - } - const milliseconds = timestamp < 100000000000 ? timestamp * 1000 : timestamp; - return new Date(milliseconds).toLocaleString("zh-CN", { hour12: false }); -} - -function formatScope(value: unknown): string | undefined { - const text = getString(value); - if (text) { - return text; - } - if (!isRecord(value)) { - return undefined; - } - const beginAt = getDisplayText(value.beginAt) || getDisplayText(value.begin); - const endAt = getDisplayText(value.endAt) || getDisplayText(value.end); - if (beginAt && endAt) { - return `${beginAt} 至 ${endAt}`; - } - return beginAt || endAt; -} - -function getNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function getTimestamp(value: number | string | undefined): number { - const timestamp = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(timestamp) || timestamp <= 0) { - return Date.now(); - } - return timestamp < 100000000000 ? timestamp * 1000 : timestamp; -} - -function isFailedConversationMessage(message: GaoAiConversationMessage): boolean { - const status = getString(message.status)?.toLowerCase(); - return status === "error" || status === "failed" || status === "failure"; -} diff --git a/src/pages/ai/components/AiChatComposer.vue b/src/pages/ai/components/AiChatComposer.vue deleted file mode 100644 index 4f3fcc7..0000000 --- a/src/pages/ai/components/AiChatComposer.vue +++ /dev/null @@ -1,135 +0,0 @@ - - - diff --git a/src/pages/ai/components/AiChatMessageList.vue b/src/pages/ai/components/AiChatMessageList.vue deleted file mode 100644 index 37b4aa6..0000000 --- a/src/pages/ai/components/AiChatMessageList.vue +++ /dev/null @@ -1,402 +0,0 @@ - - - diff --git a/src/pages/ai/components/AiChatPanel.vue b/src/pages/ai/components/AiChatPanel.vue index ec723c9..56fb420 100644 --- a/src/pages/ai/components/AiChatPanel.vue +++ b/src/pages/ai/components/AiChatPanel.vue @@ -1,30 +1,63 @@ + + diff --git a/src/pages/ai/components/AiChatThinking.vue b/src/pages/ai/components/AiChatThinking.vue deleted file mode 100644 index c0281e4..0000000 --- a/src/pages/ai/components/AiChatThinking.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - diff --git a/src/pages/ai/components/AiConversationList.vue b/src/pages/ai/components/AiConversationList.vue index e23e499..6fc937f 100644 --- a/src/pages/ai/components/AiConversationList.vue +++ b/src/pages/ai/components/AiConversationList.vue @@ -235,7 +235,7 @@ function formatConversationTime(conversation: GaoAiConversation): number { const value = conversation["updated_at"] || conversation["created_at"]; const timestamp = typeof value === "number" ? value : Number(value); if (!Number.isFinite(timestamp) || timestamp <= 0) { - return "时间未知"; + return -1; } return timestamp < 100000000000 ? timestamp * 1000 : timestamp; } diff --git a/src/pages/customer/CustomerDetail.vue b/src/pages/customer/CustomerDetail.vue index f0a00f6..c3d310d 100644 --- a/src/pages/customer/CustomerDetail.vue +++ b/src/pages/customer/CustomerDetail.vue @@ -34,8 +34,8 @@ - + + + userStore.hasPermissions(permissionCode const canReadTag = computed(() => userStore.hasPermissions(corePermissionCodes.tag.read)); const canApplyTag = computed(() => userStore.hasPermissions(corePermissionCodes.tag.apply)); const canUpdateCustomer = computed(() => userStore.hasPermissions(permissionCodes.customer.update)); +const canReadFollowUpReminder = computed(() => userStore.hasPermissions(permissionCodes.customerFollowUpReminder.read)); // ---------- 资料 ---------- @@ -389,6 +393,16 @@ async function toInviteeList(): Promise { await router.push(`/management/customers/${customer.value.id}/invitees`); } +async function toFollowUpReminderList(): Promise { + if (!customer.value?.id || !canReadFollowUpReminder.value) { + return; + } + await router.push({ + name: "CustomerFollowUpReminderList", + query: { customerId: customer.value.id } + }); +} + async function toEventRecordList(): Promise { if (!canReadEventRecord.value || !customer.value?.id) { return; @@ -531,7 +545,7 @@ onUnmounted(() => navBarStore.clearRight()); } } - :deep(.photo-scroller) { + :deep(.media-selector) { padding: 1rem; } diff --git a/src/pages/customer/CustomerEdit.vue b/src/pages/customer/CustomerEdit.vue index 66e7b46..78cff9d 100644 --- a/src/pages/customer/CustomerEdit.vue +++ b/src/pages/customer/CustomerEdit.vue @@ -71,7 +71,7 @@
-
当前账户权限编辑客户需要上传照片和积分卡
@@ -176,7 +178,23 @@ ref="photoInputRef" class="file-input" type="file" + accept="*/*" + @change="handlePhotoSelected" + > + + { navBarStore.setRightDisabled(value); if (!value) { @@ -485,6 +527,7 @@ async function loadDetail() { .map(item => ({ attachment: item, attachType: normalizeAttachmentType(item.attachType), + mimeType: item.mimeType, previewUrl: getAttachmentReadURL(item.id || ""), url: getAttachmentReadURL(item.id || "", 90) })); @@ -531,24 +574,14 @@ watch(() => formData.birthdate, (value) => { // ---------- 照片 ---------- const photoInputRef = ref(); +const photoCameraInputRef = ref(); +const videoInputRef = ref(); const photoAddActions = [ - { - icon: "user-avatar", - text: "拍照片", - emit: "add-photo", - theme: "primary" - }, { icon: "cardmembership", text: "拍积分卡", emit: "add-point-card", theme: "primary" - }, - { - icon: "file-attachment", - text: "其他文件", - emit: "add-other", - theme: "default" } ] as const; const photoPickerTarget = ref("PHOTO"); @@ -602,6 +635,7 @@ async function handlePhotoSelected(event: Event): Promise { if (tempFile?.id) { photoItemList.value.push({ attachType: photoPickerTarget.value, + mimeType: file.type, tempFileId: tempFile.id, url: AttachmentAPI.getTempReadURL(tempFile.id) }); diff --git a/src/pages/event/EventRecordDetail.vue b/src/pages/event/EventRecordDetail.vue index 1fd96b1..55c98f4 100644 --- a/src/pages/event/EventRecordDetail.vue +++ b/src/pages/event/EventRecordDetail.vue @@ -65,7 +65,7 @@ 核销登记记录
-
-
@@ -170,10 +172,26 @@ ref="recordPhotoInputRef" class="file-input" type="file" - accept="image/*" + accept="*/*" multiple @change="handleRecordPhotoSelected" > + +
(); + const recordPhotoCameraInputRef = ref(); + const videoInputRef = ref(); const photoItemList = ref([]); const isPhotoUploading = ref(false); const photoList = computed({ @@ -19,9 +21,17 @@ export function useEventRecordPhoto() { }); function capturePhoto(): void { + recordPhotoCameraInputRef.value?.click(); + } + + function selectPhotoFile(): void { recordPhotoInputRef.value?.click(); } + function captureVideo(): void { + videoInputRef.value?.click(); + } + async function handleRecordPhotoSelected(event: Event): Promise { const input = event.target as HTMLInputElement; const fileList = Array.from(input.files || []); @@ -32,11 +42,12 @@ export function useEventRecordPhoto() { try { const compressedFileList = await compressUploadImageList(fileList); const tempFileList = await AttachmentAPI.upload(compressedFileList); - for (const item of tempFileList) { + for (const [index, item] of tempFileList.entries()) { if (!item.id) { continue; } photoItemList.value.push({ + mimeType: fileList[index]?.type, tempFileId: item.id, url: AttachmentAPI.getTempReadURL(item.id) }); @@ -53,6 +64,7 @@ export function useEventRecordPhoto() { .filter(item => !!item.id) .map(item => ({ attachment: item, + mimeType: item.mimeType, previewUrl: getAttachmentReadURL(item.id || ""), url: getAttachmentReadURL(item.id || "", 90) })); @@ -80,10 +92,14 @@ export function useEventRecordPhoto() { return { recordPhotoInputRef, + recordPhotoCameraInputRef, + videoInputRef, photoItemList, isPhotoUploading, photoList, capturePhoto, + selectPhotoFile, + captureVideo, handleRecordPhotoSelected, setExistingPhotoList, buildTempFileIdList, diff --git a/src/pages/followUpReminder/FollowUpReminderDetail.vue b/src/pages/followUpReminder/FollowUpReminderDetail.vue new file mode 100644 index 0000000..b07cb1e --- /dev/null +++ b/src/pages/followUpReminder/FollowUpReminderDetail.vue @@ -0,0 +1,377 @@ + + + + + diff --git a/src/pages/followUpReminder/FollowUpReminderEdit.vue b/src/pages/followUpReminder/FollowUpReminderEdit.vue new file mode 100644 index 0000000..d9b3d35 --- /dev/null +++ b/src/pages/followUpReminder/FollowUpReminderEdit.vue @@ -0,0 +1,645 @@ + + + + + diff --git a/src/pages/followUpReminder/FollowUpReminderList.vue b/src/pages/followUpReminder/FollowUpReminderList.vue new file mode 100644 index 0000000..df116bb --- /dev/null +++ b/src/pages/followUpReminder/FollowUpReminderList.vue @@ -0,0 +1,869 @@ + + + + + diff --git a/src/pages/followUpReminder/component/ManagedUserSelector.vue b/src/pages/followUpReminder/component/ManagedUserSelector.vue new file mode 100644 index 0000000..0c57d66 --- /dev/null +++ b/src/pages/followUpReminder/component/ManagedUserSelector.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/src/pages/fulfillment/OrderFulfillmentCreate.vue b/src/pages/fulfillment/OrderFulfillmentCreate.vue index d4cff57..b0b6503 100644 --- a/src/pages/fulfillment/OrderFulfillmentCreate.vue +++ b/src/pages/fulfillment/OrderFulfillmentCreate.vue @@ -102,7 +102,7 @@ -
- -
-
+ +
(); +const recordPhotoCameraInputRef = ref(); +const videoInputRef = ref(); const recordPhotoItemList = ref([]); const recordPhotoUploading = ref(false); const showRecordPhotoInput = computed(() => selectedRequiredPhotoEventList.value.length > 0 || recordPhotoItemList.value.length > 0); @@ -400,9 +420,17 @@ const recordPhotoList = computed({ }); function capturePhoto(): void { + recordPhotoCameraInputRef.value?.click(); +} + +function selectPhotoFile(): void { recordPhotoInputRef.value?.click(); } +function captureVideo(): void { + videoInputRef.value?.click(); +} + async function handleRecordPhotoSelected(event: Event): Promise { const input = event.target as HTMLInputElement; const fileList = Array.from(input.files || []); @@ -413,13 +441,14 @@ async function handleRecordPhotoSelected(event: Event): Promise { try { const compressedFileList = await compressUploadImageList(fileList); const tempFileList = await AttachmentAPI.upload(compressedFileList); - for (const item of tempFileList) { + for (const [index, item] of tempFileList.entries()) { if (!item.id) { continue; } recordPhotoItemList.value.push({ tempFileId: item.id, attachType: "PHOTO", + mimeType: fileList[index]?.type, url: AttachmentAPI.getTempReadURL(item.id) }); } diff --git a/src/pages/package/PackagePurchase.vue b/src/pages/package/PackagePurchase.vue index ee70c22..6733208 100644 --- a/src/pages/package/PackagePurchase.vue +++ b/src/pages/package/PackagePurchase.vue @@ -209,7 +209,7 @@
- - -
-
- - - - navBarStore.clearRight()); margin: 0 0 .75rem; } - :deep(.photo-scroller) { + :deep(.media-selector) { margin: 0 .75rem .75rem; } diff --git a/src/pages/product/ProductEdit.vue b/src/pages/product/ProductEdit.vue index 50c2a64..5cdbcf3 100644 --- a/src/pages/product/ProductEdit.vue +++ b/src/pages/product/ProductEdit.vue @@ -23,7 +23,7 @@ - @@ -197,7 +199,9 @@ > - + + +
@@ -206,7 +210,7 @@ import { BottomPopup } from "timi-tdesign-mobile"; import { DialogPlugin, type ColorObject } from "tdesign-mobile-vue"; import { type Attachment, AttachmentAPI, useUserStore } from "timi-web-api"; import ProductAPI from "@/api/ProductAPI"; -import PhotoScroller from "@/components/PhotoScroller.vue"; +import MediaSelector from "@/components/mediaSelector/MediaSelector.vue"; import ProductSkuEditor from "@/pages/product/component/ProductSkuEditor.vue"; import ProductSkuUnitEditor from "@/pages/product/component/ProductSkuUnitEditor.vue"; import ProductSkuUnitBatchEditor from "@/pages/product/component/ProductSkuUnitBatchEditor.vue"; @@ -252,6 +256,8 @@ const typePickerVisible = ref(false); const categoryPickerVisible = ref(false); const statusPickerVisible = ref(false); const attachmentInputRef = ref(); +const photoInputRef = ref(); +const videoInputRef = ref(); const attachmentPickerTarget = ref("PHOTO"); const isAttachmentUploading = ref(false); const coverColorPickerVisible = ref(false); @@ -269,7 +275,6 @@ const statusOptions = [{ label: "启用", value: "ACTIVE" }, { label: "停用", const colorSwatchList = ["#2F88FF", "#0EA5E9", "#10B981", "#22C55E", "#F59E0B", "#F97316", "#EF4444", "#EC4899", "#8B5CF6", "#64748B"]; const selectedCategoryName = computed(() => categoryList.value.find(item => item.id === form.categoryId)?.name || "不设置分类"); const attachmentAddActions = [ - { icon: "camera", text: "产品图片", emit: "add-photo", theme: "primary" }, { icon: "file-attachment", text: "其他文件", emit: "add-other", theme: "default" } ] as const; const attachmentItemList = ref([]); @@ -552,6 +557,16 @@ function openAttachmentPicker(target: ProductAttachmentType): void { attachmentInputRef.value?.click(); } +function capturePhoto(): void { + attachmentPickerTarget.value = "PHOTO"; + photoInputRef.value?.click(); +} + +function captureVideo(): void { + attachmentPickerTarget.value = "OTHER"; + videoInputRef.value?.click(); +} + function openCoverColorPicker(): void { coverColorDraft.value = solidColorCoverColor.value || DEFAULT_COVER_COLOR; coverColorPickerVisible.value = true; @@ -628,7 +643,7 @@ async function handleAttachmentSelected(event: Event): Promise { try { const tempFile = (await AttachmentAPI.upload(await compressUploadImage(file)))[0]; if (tempFile?.id) { - attachmentItemList.value.push({ attachType: attachmentPickerTarget.value, tempFileId: tempFile.id, url: AttachmentAPI.getTempReadURL(tempFile.id) }); + attachmentItemList.value.push({ attachType: attachmentPickerTarget.value, mimeType: file.type, tempFileId: tempFile.id, url: AttachmentAPI.getTempReadURL(tempFile.id) }); } } catch (error) { Message.error(getErrorMessage(error, "产品附件上传失败")); diff --git a/src/pages/product/ProductStockOperationPopup.vue b/src/pages/product/ProductStockOperationPopup.vue index 965f2ca..06c0ca8 100644 --- a/src/pages/product/ProductStockOperationPopup.vue +++ b/src/pages/product/ProductStockOperationPopup.vue @@ -60,7 +60,7 @@
-