Compare commits

...
23 Commits
Author SHA1 Message Date
Timi c6b398fa98 add customer for FundRecordList.vue 2026-09-17 01:29:58 +08:00
Timi b6676877c3 fix introducer save fail 2026-09-17 01:24:28 +08:00
Timi 47723c6403 add customer or operator info 2026-09-17 01:23:17 +08:00
Timi 3135c41b21 update aiChatQuestions.ts from api 2026-09-17 01:00:30 +08:00
Timi 1be261f944 dynamic agreement list 2026-09-16 23:48:50 +08:00
Timi a0fac39c99 add AiConversationList.vue 2026-09-16 21:32:03 +08:00
Timi 91938f2427 add AIAgent think 2026-09-16 16:15:24 +08:00
Timi f8a9c0e649 add product stock preview 2026-09-16 14:53:36 +08:00
Timi 443d0a6190 hidden sku code 2026-09-16 14:53:15 +08:00
Timi d0969cb322 auto verify for order fulfillment and payment 2026-09-16 13:07:05 +08:00
Timi b201f5cd58 add customerCount for list event record 2026-09-16 12:54:56 +08:00
Timi 0d95b4b562 fix cancel for waiting stock order 2026-09-16 01:40:28 +08:00
Timi 5dcf77f79f add unit transfer 2026-09-16 01:39:57 +08:00
Timi a6f9fabcec add AIAgent 2026-09-16 01:39:44 +08:00
Timi c29436f845 fix last cell line 2026-09-15 14:33:12 +08:00
Timi b661101a4a add date util 2026-09-15 13:41:33 +08:00
Timi 151427a675 fix SalesOrderDetail.vue style 2026-09-15 13:20:53 +08:00
Timi dd30d4cbcc fix button style 2026-09-15 13:13:39 +08:00
Timi b8bb44e62a total value in t-cell 2026-09-15 13:11:56 +08:00
Timi a6577482ac fix transition 2026-09-15 13:10:43 +08:00
Timi 8c9842c193 add photo 2026-09-15 12:26:47 +08:00
Timi 3b31976e31 fix active style 2026-09-15 12:19:44 +08:00
Timi 5da177dbf8 fix title for back 2026-09-15 11:47:06 +08:00
52 changed files with 3838 additions and 779 deletions
+4 -1
View File
@@ -12,6 +12,9 @@ async function detail(id: string): Promise<OrderFulfillment> {
async function create(request: OrderFulfillmentCreateRequest): Promise<OrderFulfillment> {
return await axios.post(`${URI}/create`, request);
}
async function createAndVerify(request: OrderFulfillmentCreateRequest): Promise<OrderFulfillment> {
return await axios.post(`${URI}/create-and-verify`, request);
}
async function prepareStock(request: FulfillmentPrepareStockRequest): Promise<OrderFulfillment> {
return await axios.post(`${URI}/prepare-stock`, request);
}
@@ -28,4 +31,4 @@ async function revoke(request: FulfillmentRevokeRequest): Promise<OrderFulfillme
return await axios.post(`${URI}/revoke`, request);
}
export default { list, detail, create, prepareStock, verify, reject, cancel, revoke };
export default { list, detail, create, createAndVerify, prepareStock, verify, reject, cancel, revoke };
+271
View File
@@ -0,0 +1,271 @@
import { axios } from "timi-web-api";
/** 门店智能助手聊天请求。 */
export interface GaoAiChatRequest {
message: string;
conversationId?: string;
/** GLOBAL_MANAGER 兼任店长时使用当前选中门店。 */
storeId?: string;
}
/** 历史会话列表排序方式。 */
export type GaoAiConversationSortBy = "created_at" | "-created_at" | "updated_at" | "-updated_at";
/** 历史会话列表请求。 */
export interface GaoAiConversationListRequest {
lastId?: string;
limit?: number;
sortBy?: GaoAiConversationSortBy;
}
/** 历史会话。 */
export interface GaoAiConversation {
id: string;
name?: string;
status?: string;
introduction?: string;
"created_at"?: number | string;
"updated_at"?: number | string;
}
/** 历史会话列表响应。 */
export interface GaoAiConversationListResponse {
"has_more"?: boolean;
limit?: number;
data?: GaoAiConversation[];
}
/** 历史会话消息读取请求。 */
export interface GaoAiConversationReadRequest {
conversationId: string;
firstId?: string;
limit?: number;
}
/** 历史会话消息。 */
export interface GaoAiConversationMessage {
id: string;
"conversation_id"?: string;
query?: string;
answer?: string;
"created_at"?: number | string;
status?: string;
feedback?: unknown;
}
/** 历史会话消息列表响应。 */
export interface GaoAiConversationReadResponse {
"has_more"?: boolean;
limit?: number;
data?: GaoAiConversationMessage[];
}
/** 智能助手 SSE 事件。 */
export interface GaoAiStreamEvent {
event: string;
data: string;
}
/** 智能助手 HTTP 错误。 */
export class GaoAiHttpError extends Error {
readonly status: number;
readonly code?: string;
constructor(status: number, message: string, code?: string) {
super(message);
this.name = "GaoAiHttpError";
this.status = status;
this.code = code;
}
}
/** 智能助手流式响应错误。 */
export class GaoAiStreamError extends Error {
readonly code?: string;
constructor(message: string, code?: string) {
super(message);
this.name = "GaoAiStreamError";
this.code = code;
}
}
const chatPath = "/gao/ai/chat";
const conversationBasePath = "/gao/ai/conversation";
/**
* 查询当前登录用户的历史会话。
*
* @param request 历史会话分页请求
*/
export async function listConversations(
request: GaoAiConversationListRequest = {}
): Promise<GaoAiConversationListResponse> {
return await axios.post(`${conversationBasePath}/list`, request);
}
/**
* 读取指定历史会话的消息。
*
* @param request 历史会话读取请求
*/
export async function readConversation(
request: GaoAiConversationReadRequest
): Promise<GaoAiConversationReadResponse> {
return await axios.post(`${conversationBasePath}/read`, request);
}
/**
* 删除指定历史会话。
*
* @param conversationId 会话 ID
*/
export async function deleteConversation(conversationId: string): Promise<void> {
await axios.post(`${conversationBasePath}/delete`, undefined, {
params: { conversationId }
});
}
/**
* 发起门店智能助手聊天请求。
*
* @param request 聊天请求
* @param token 当前登录令牌
* @param signal 请求取消信号
*/
export async function chat(request: GaoAiChatRequest, token: string, signal: AbortSignal): Promise<Response> {
const apiBaseURL = import.meta.env.VITE_API_BASE_URL as string | undefined;
const url = new URL(chatPath, apiBaseURL || window.location.origin);
const headers: Record<string, string> = {
Accept: "text/event-stream",
"Content-Type": "application/json"
};
if (token) {
// 系统现有接口使用 Token;Authorization 兼容智能助手实施协议。
headers.Authorization = `Bearer ${token}`;
headers.Token = token;
}
return await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(request),
credentials: "include",
signal
});
}
/**
* 读取并解析 SSE 流。
*
* @param response 聊天响应
* @param onEvent 事件处理函数
*/
export async function readSseStream(
response: Response,
onEvent: (event: GaoAiStreamEvent) => Promise<void>
): Promise<void> {
if (!response.body) {
throw new GaoAiStreamError("智能助手没有返回可读取的数据流", "EMPTY_STREAM");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
buffer = await consumeSseBuffer(buffer, onEvent, false);
}
buffer += decoder.decode();
await consumeSseBuffer(buffer, onEvent, true);
} finally {
reader.releaseLock();
}
}
async function consumeSseBuffer(
buffer: string,
onEvent: (event: GaoAiStreamEvent) => Promise<void>,
isFinal: boolean
): Promise<string> {
const normalizedBuffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
const blockList = normalizedBuffer.split("\n\n");
const remain = isFinal ? "" : blockList.pop() || "";
for (const block of blockList) {
const event = parseSseBlock(block);
if (event) {
await onEvent(event);
}
}
if (isFinal && remain.trim()) {
const event = parseSseBlock(remain);
if (event) {
await onEvent(event);
}
}
return remain;
}
function parseSseBlock(block: string): GaoAiStreamEvent | undefined {
let event = "message";
const dataLineList: string[] = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) {
event = line.slice(6).trim() || "message";
} else if (line.startsWith("data:")) {
const data = line.slice(5);
dataLineList.push(data.startsWith(" ") ? data.slice(1) : data);
}
}
if (!dataLineList.length) {
return undefined;
}
return { event, data: dataLineList.join("\n") };
}
/** 将非 2xx 响应转换为不携带原始响应正文的错误。 */
export async function ensureChatResponse(response: Response): Promise<void> {
if (response.ok) {
return;
}
let code: string | undefined;
let message = "智能助手请求失败,请稍后重试";
try {
const payload = await response.json() as {
code?: unknown;
message?: unknown;
error?: unknown;
};
if (typeof payload.code === "string") {
code = payload.code;
}
if (typeof payload.message === "string" && payload.message.trim()) {
message = payload.message.trim();
} else if (typeof payload.error === "string" && payload.error.trim()) {
message = payload.error.trim();
}
} catch {
// 非 JSON 错误响应使用上面的安全默认文案。
}
throw new GaoAiHttpError(response.status, message, code);
}
/** 解析 SSE 数据,解析失败时返回原始文本。 */
export function parseSseData(data: string): unknown {
if (data === "[DONE]") {
return data;
}
try {
return JSON.parse(data) as unknown;
} catch {
return data;
}
}
+6
View File
@@ -9,6 +9,7 @@ import type {
ProductSpec,
ProductSpecValue,
ProductStock,
ProductStockBatchRequest,
ProductStockProduct,
ProductStockChangeRequest,
ProductStockManualRevokeRequest,
@@ -107,6 +108,10 @@ async function stockDetail(skuId: string, storeId?: string): Promise<ProductStoc
});
}
async function stockBatchDetail(request: ProductStockBatchRequest): Promise<ProductStock[]> {
return await axios.post(`${STOCK_URI}/batch-detail`, withCurrentStoreId(request));
}
async function initialIn(request: ProductStockChangeRequest): Promise<ProductStock> {
return await axios.post(`${STOCK_URI}/initial-in`, withCurrentStoreId(request));
}
@@ -161,6 +166,7 @@ export default {
stockList,
stockProductList,
stockDetail,
stockBatchDetail,
initialIn,
manualIn,
manualOut,
+4
View File
@@ -37,6 +37,9 @@ async function cancel(request: SalesOrderCancelRequest): Promise<SalesOrder> {
async function receivePayment(request: OrderPaymentReceiveRequest): Promise<FundRecord> {
return await axios.post(`${ORDER_URI}/payment/receive`, request);
}
async function receivePaymentAndVerify(request: OrderPaymentReceiveRequest): Promise<FundRecord> {
return await axios.post(`${ORDER_URI}/payment/receive-and-verify`, request);
}
async function verifyPayment(id: string): Promise<FundRecord> {
return await axios.post(`${ORDER_URI}/payment/verify`, undefined, { params: { id } });
}
@@ -77,6 +80,7 @@ export default {
update,
cancel,
receivePayment,
receivePaymentAndVerify,
verifyPayment,
rejectPayment,
cancelPayment,
+7 -1
View File
@@ -92,6 +92,11 @@ html.theme-dark body {
gap: 1rem;
}
.t-pull-down-refresh {
overflow-x: hidden;
overflow-y: auto;
}
.t-dropdown-menu {
&:after {
@@ -111,9 +116,10 @@ html.theme-dark body {
.center-tips {
color: var(--tui-gray);
margin: 0 auto;
padding: .75rem;
text-align: center;
font-size: .875rem;
text-align: center;
}
.avatar {
+10 -2
View File
@@ -123,7 +123,7 @@
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import ProductAPI from "@/api/ProductAPI";
import type { Product, ProductSku, ProductSkuUnit, ProductSpec, ProductSpecValue } from "@/types/Product";
import type { Product, ProductSelection, ProductSku, ProductSkuUnit, ProductSpec, ProductSpecValue } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { formatProductAmount, getProductPhotoURL, getProductSkuSpecEntries } from "@/utils/product";
@@ -137,7 +137,10 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), { label: "产品", requiredMark: false, placeholder: "请选择产品 SKU" });
const emit = defineEmits<{ "update:modelValue": [value: Props["modelValue"]] }>();
const emit = defineEmits<{
"update:modelValue": [value: Props["modelValue"]];
select: [selection: ProductSelection];
}>();
const visible = ref(false);
const loading = ref(false);
const keyword = ref("");
@@ -242,6 +245,11 @@ function selectUnit(sku: ProductSku, skuUnitId: string): void {
if (!sku.id || !skuUnitId) {
return;
}
const product = activeProduct.value;
const unit = sku.unitList?.find(item => item.id === skuUnitId);
if (product && unit) {
emit("select", { product, sku, unit });
}
emit("update:modelValue", { skuId: sku.id, skuUnitId });
close();
}
@@ -8,7 +8,6 @@
variant="light-outline"
:content="tag.content"
/>
<t-tag v-if="unitText" size="small" :content="unitText" />
</div>
</template>
+81
View File
@@ -0,0 +1,81 @@
<template>
<t-table
class="product-stock-preview"
:columns="columns"
:data="items"
row-key="skuId"
table-layout="fixed"
vertical-align="middle"
>
<template #product="{ row }">
<span class="product clip-text" v-text="row.productName || '未命名产品'" />
</template>
<template #required="{ row }">
<span v-text="row.baseQuantity" />
</template>
<template #available="{ row }">
<span v-text="row.availableQuantity" />
</template>
<template #check="{ row }">
<strong :class="row.shortageQuantity ? 'shortage' : 'sufficient'" v-text="row.shortageQuantity ? '缺少' : '充足'" />
</template>
<template #footerSummary>
<div class="orange tips">仅供参考,以实际提交为准</div>
</template>
</t-table>
</template>
<script setup lang="ts">
import type { ProductStockPreviewItem } from "@/utils/productStock";
defineOptions({
name: "ProductStockPreview"
});
defineProps<{
items: ProductStockPreviewItem[];
}>();
const columns = [
{ colKey: "product", title: "产品", width: "40%", ellipsis: true },
{ colKey: "required", title: "需求", width: "20%", align: "right", cell: "required" },
{ colKey: "available", title: "可用", width: "20%", align: "right", cell: "available" },
{ colKey: "check", title: "校验", width: "20%", align: "center", cell: "check" }
];
</script>
<style scoped lang="less">
.product-stock-preview {
--td-component-border: transparent;
.product {
min-width: 0;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sufficient {
color: var(--td-success-color);
}
.shortage {
color: var(--td-error-color);
}
:deep(.t-table__content) {
overflow-x: hidden;
}
:deep(th),
:deep(td) {
padding: .25rem .375rem;
}
.tips {
text-align: center;
}
}
</style>
+696
View File
@@ -0,0 +1,696 @@
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<string>;
storeId?: MaybeRefOrGetter<string | undefined>;
scrollToBottom?: () => Promise<void>;
}
const conversationExpiredText = "AI 会话不存在或已过期";
const thinkOpenMarkers = ["<thinking>", "<think>"];
const thinkCloseMarkers = ["</thinking>", "</think>"];
interface AssistantResponseState {
rawText: string;
eventThinkingList: AiThinking[];
taggedThinkingList: AiThinking[];
}
/** 管理门店智能助手的会话、流式消息和取消操作。 */
export function useAiChat(options: UseAiChatOptions) {
const userStore = useUserStore();
const messages = shallowRef<AiChatMessage[]>([]);
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<number, AssistantResponseState>();
async function sendMessage(message: string): Promise<void> {
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<void> {
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<GaoAiConversationReadResponse> {
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<void> {
if (canRetry.value) {
await sendMessage(lastQuestion.value);
}
}
async function readChatStream(message: string, assistantId: number, controller: AbortController): Promise<void> {
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<boolean> {
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<AiChatMessage>): 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<void> {
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<string, unknown> {
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";
}
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useUserStore } from "timi-web-api/store";
import AiChatPanel from "@/pages/ai/components/AiChatPanel.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { gaoRoleCodes } from "@/utils/permission";
defineOptions({
name: "AiChatPage"
});
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const sessionKey = computed(() => `${userStore.loginUser.user?.id || userStore.loginUser.id || ""}:${currentStoreStore.currentStoreId}`);
const canUseAi = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager));
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const aiStoreId = computed(() => canUseAi.value && isGlobalManager.value ? currentStoreStore.currentStoreId : undefined);
</script>
<template>
<div v-if="canUseAi" class="ai-chat-page">
<ai-chat-panel
:store-name="currentStoreStore.currentStoreName"
:session-key="sessionKey"
:store-id="aiStoreId"
/>
</div>
<t-empty v-else description="当前账号无权使用门店智能助手" />
</template>
<style scoped lang="less">
.ai-chat-page {
height: 100%;
overflow: hidden;
min-height: 0;
}
:global(html.theme-dark) {
.ai-chat-page {
background: #10161f;
}
}
</style>
+141
View File
@@ -0,0 +1,141 @@
import { ref } from "vue";
import { SettingAPI } from "timi-web-api";
const AI_QUICK_QUESTION_CACHE_KEY = "gao-customer-h5:ai-agent-example";
const AI_QUICK_QUESTION_CACHE_TTL = 24 * 60 * 60 * 1000;
const AI_QUICK_QUESTION_COUNT = 5;
interface AiQuickQuestionCache {
cachedAt: number;
questionList: string[];
}
/** 从快捷问题题库中随机抽取指定数量的问题。 */
export function getRandomAiQuickQuestions(count = AI_QUICK_QUESTION_COUNT, questionPool: readonly string[] = []): string[] {
const result: string[] = [...questionPool];
const targetCount = Math.min(Math.max(Math.floor(count), 0), result.length);
for (let index = 0; index < targetCount; index += 1) {
const randomIndex = index + Math.floor(Math.random() * (result.length - index));
[result[index], result[randomIndex]] = [result[randomIndex], result[index]];
}
return result.slice(0, targetCount);
}
/** 清理并去重快捷问题。 */
function normalizeAiQuickQuestionList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return [...new Set(value.filter((item): item is string => typeof item === "string").map(item => item.trim()).filter(Boolean))];
}
/** 将 Setting 中的题库值解析为有效的快捷问题列表。 */
function parseAiQuickQuestionList(value: unknown): string[] {
if (typeof value !== "string") {
return [];
}
try {
return normalizeAiQuickQuestionList(JSON.parse(value));
} catch {
return [];
}
}
/** 读取一天内有效的本地题库缓存。 */
function readAiQuickQuestionCache(): string[] {
try {
const cacheValue = localStorage.getItem(AI_QUICK_QUESTION_CACHE_KEY);
if (!cacheValue) {
return [];
}
const cache: unknown = JSON.parse(cacheValue);
if (typeof cache !== "object" || cache === null || !("cachedAt" in cache) || !("questionList" in cache)) {
return [];
}
const typedCache = cache as AiQuickQuestionCache;
if (typeof typedCache.cachedAt !== "number" || !Number.isFinite(typedCache.cachedAt)) {
return [];
}
const cacheAge = Date.now() - typedCache.cachedAt;
if (cacheAge < 0 || AI_QUICK_QUESTION_CACHE_TTL < cacheAge || !Array.isArray(typedCache.questionList)) {
return [];
}
return normalizeAiQuickQuestionList(typedCache.questionList);
} catch {
return [];
}
}
/** 将动态题库缓存到本地一天。 */
function writeAiQuickQuestionCache(questionList: string[]): void {
try {
const cache: AiQuickQuestionCache = {
cachedAt: Date.now(),
questionList
};
localStorage.setItem(AI_QUICK_QUESTION_CACHE_KEY, JSON.stringify(cache));
} catch {
// 本地存储不可用时跳过缓存。
}
}
/** 从系统设置中获取动态快捷问题题库。 */
async function fetchAiQuickQuestionPool(): Promise<string[]> {
const settingMap = await SettingAPI.map("GAO_SYSTEM", undefined, ["AI_AGENT_EXAMPLE"]);
return parseAiQuickQuestionList(settingMap.AI_AGENT_EXAMPLE?.value);
}
let loadingPromise: Promise<void> | undefined;
let currentAiQuickQuestionPool: string[] = [];
/** 从已加载的题库中重新抽取快捷问题。 */
export function refreshAiQuickQuestions(): void {
aiQuickQuestionList.value = getRandomAiQuickQuestions(AI_QUICK_QUESTION_COUNT, currentAiQuickQuestionPool);
}
/** 加载动态题库并刷新当前会话展示的问题。 */
export async function loadAiQuickQuestions(): Promise<void> {
if (loadingPromise) {
await loadingPromise;
return;
}
loadingPromise = (async () => {
const cachedQuestionList = readAiQuickQuestionCache();
if (cachedQuestionList.length > 0) {
currentAiQuickQuestionPool = cachedQuestionList;
refreshAiQuickQuestions();
return;
}
currentAiQuickQuestionPool = [];
aiQuickQuestionList.value = [];
try {
const questionList = await fetchAiQuickQuestionPool();
if (questionList.length > 0) {
currentAiQuickQuestionPool = questionList;
writeAiQuickQuestionCache(questionList);
refreshAiQuickQuestions();
}
} catch {
// 接口异常时保持为空。
}
})();
try {
await loadingPromise;
} finally {
loadingPromise = undefined;
}
}
/** 当前会话欢迎页展示的快捷问题。 */
export const aiQuickQuestionList = ref<string[]>([]);
+135
View File
@@ -0,0 +1,135 @@
<template>
<div class="composer">
<div v-if="lastError" class="error-bar">
<span class="error-text" v-text="lastError" />
<t-button v-if="canRetry" variant="text" size="small" content="重试" @click="emit('retry')" />
</div>
<t-form class="input-wrap glass-white" @submit="submitMessage">
<t-input
v-model="draft"
class="input"
borderless
:disabled="isGenerating || props.disabled"
:maxlength="256"
placeholder="问问小糕怎么个事…"
/>
<t-button
v-if="isGenerating"
class="send"
type="button"
size="small"
theme="danger"
content="停止"
@click="emit('stop')"
/>
<t-button
v-else
class="send"
type="submit"
size="small"
theme="primary"
content="发送"
/>
</t-form>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: "AiChatComposer"
});
interface Props {
isGenerating: boolean;
lastError: string;
canRetry: boolean;
disabled?: boolean;
}
const props = defineProps<Props>();
const draft = defineModel<string>({ required: true });
const emit = defineEmits<{
send: [message: string];
stop: [];
retry: [];
}>();
function submitMessage(): void {
if (props.disabled) {
return;
}
const message = draft.value.trim();
if (!message) {
return;
}
draft.value = "";
emit("send", message);
}
</script>
<style scoped lang="less">
.composer {
width: 100%;
.error-bar {
gap: .375rem;
display: flex;
padding: .5rem .75rem;
border-radius: .75rem;
color: #b42318;
font-size: .75rem;
background: #fef3f2;
align-items: center;
justify-content: space-between;
.error-text {
min-width: 0;
}
.t-button {
color: #b42318;
flex: none;
}
}
.input-wrap {
--td-input-bg-color: transparent;
--td-button-border-radius: 1rem;
gap: .5rem;
border: 1px solid var(--td-component-border);
margin: .5rem 0 0 0;
display: flex;
padding: .25rem .25rem .25rem .75rem;
box-shadow: 0 .5rem 1.5rem rgba(15, 23, 42, .12);
align-items: center;
border-radius: 1.25rem;
.input {
min-width: 0;
flex: 1;
}
.send {
width: 4rem;
flex: none;
}
}
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
:global(html.theme-dark) {
.error-bar {
color: #fda29b;
background: rgba(180, 35, 24, .18);
.t-button {
color: #fda29b;
}
}
}
</style>
@@ -0,0 +1,402 @@
<template>
<div ref="messageList" class="message-list" role="log" aria-live="polite">
<div v-if="!props.messages.length" class="welcome">
<div class="welcome-icon"><t-icon name="robot-2" size="2rem" /></div>
<h3>今天想了解什么?</h3>
<p>我会根据当前门店的实时业务数据回答经营问题。</p>
<div class="quick-list">
<t-button
v-for="question in props.quickQuestionList"
:key="question"
class="quick-question"
variant="outline"
size="small"
@click="emit('selectQuestion', question)"
>
<span v-text="question" />
</t-button>
</div>
<t-button
v-if="props.quickQuestionList.length"
class="refresh-button"
variant="text"
size="small"
:disabled="isRefreshingQuestions"
@click="handleRefreshQuestions"
>
<template #icon>
<t-icon name="refresh" class="refresh-icon" :class="{ refreshing: isRefreshingQuestions }" />
</template>
<span>换一批</span>
</t-button>
</div>
<article
v-for="message in props.messages"
:key="message.id"
class="message-row"
:class="message.role"
>
<div class="message-header">
<div class="avatar">
<t-icon :name="message.role === 'assistant' ? 'robot-2' : 'user'" />
</div>
<div class="role-name" v-text="message.role === 'assistant' ? 'AI 小糕' : '店长'" />
</div>
<div class="message-main">
<div class="bubble" :class="message.role">
<template v-if="message.role === 'assistant'">
<ai-chat-thinking
v-for="thinking in message.thinkingList"
:key="thinking.id"
:content="thinking.content"
:is-generating="props.isGenerating && thinking.id === message.thinkingList[message.thinkingList.length - 1]?.id"
/>
</template>
<div v-if="message.content && message.role === 'assistant'" class="content markdown-content">
<markdown-view :content="message.content" />
</div>
<div v-else-if="message.content" class="content" v-text="message.content" />
<div v-else-if="message.role === 'assistant' && props.isGenerating" class="typing">
<span /><span /><span />
</div>
<div v-if="message.role === 'assistant' && message.citationList.length" class="citation-list">
<div
v-for="(citation, index) in message.citationList"
:key="`${message.id}-${index}`"
class="citation"
>
<span class="citation-label">数据来源</span>
<span v-text="getCitationText(citation)" />
<span v-if="citation.generatedAt" class="citation-time" v-text="`生成时间:${citation.generatedAt}`" />
</div>
</div>
<div v-if="message.role === 'assistant' && (message.statusText || (!props.isGenerating && message.content))" class="message-status" v-text="getMessageStatus(message)" />
</div>
</div>
</article>
</div>
</template>
<script setup lang="ts">
import { MarkdownView, Toolkit } from "timi-web";
import type { AiChatMessage } from "@/composables/useAiChat";
import AiChatThinking from "@/pages/ai/components/AiChatThinking.vue";
defineOptions({
name: "AiChatMessageList"
});
interface Props {
messages: AiChatMessage[];
isGenerating: boolean;
quickQuestionList: readonly string[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
selectQuestion: [question: string];
refreshQuestions: [];
}>();
const messageListRef = useTemplateRef<HTMLElement>("messageList");
const isRefreshingQuestions = ref(false);
async function handleRefreshQuestions(): Promise<void> {
if (isRefreshingQuestions.value) {
return;
}
isRefreshingQuestions.value = true;
emit("refreshQuestions");
await Toolkit.sleep(300);
isRefreshingQuestions.value = false;
}
async function scrollToBottom(): Promise<void> {
await nextTick();
const element = messageListRef.value;
if (element) {
element.scrollTop = element.scrollHeight;
}
}
function getMessageStatus(message: AiChatMessage): string {
if (message.statusText) {
return message.statusText;
}
const timeText = `生成时间:${formatDateTime(message.createdAt)}`;
if (message.duration !== undefined) {
return `${timeText} · 查询耗时:${formatDuration(message.duration)}`;
}
return timeText;
}
function getCitationText(citation: AiChatMessage["citationList"][number]): string {
const textList = [
citation.dataDomain ? `数据域:${citation.dataDomain}` : "",
citation.timeRange ? `统计范围:${citation.timeRange}` : "",
citation.caliber ? `统计口径:${citation.caliber}` : "",
citation.source ? `来源:${citation.source}` : ""
].filter(Boolean);
return textList.join(" · ");
}
function formatDateTime(timestamp: number): string {
return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
}
function formatDuration(duration: number): string {
if (duration < 1000) {
return `${duration} 毫秒`;
}
return `${(duration / 1000).toFixed(1)} 秒`;
}
defineExpose({
scrollToBottom
});
</script>
<style scoped lang="less">
.message-list {
gap: 1rem;
flex: 1;
display: flex;
padding: .5rem 1rem calc(6rem + env(safe-area-inset-bottom, 0px));
min-height: 0;
overflow-y: auto;
flex-direction: column;
.welcome {
margin: auto 0;
text-align: center;
.welcome-icon {
color: #0f766e;
width: 4rem;
height: 4rem;
margin: 0 auto 1rem;
border-radius: 1.25rem;
display: flex;
background: #ccfbf1;
align-items: center;
justify-content: center;
}
h3 {
margin: 0;
font-size: 1.125rem;
}
p {
color: var(--td-text-color-secondary);
margin: .5rem auto 1rem;
font-size: .8125rem;
line-height: 1.5;
}
.quick-list {
gap: .5rem;
display: flex;
flex-direction: column;
justify-content: center;
.quick-question {
overflow: hidden;
min-width: 0;
max-width: 100%;
:deep(.t-button__content) {
display: block;
overflow: hidden;
min-width: 0;
max-width: 100%;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.refresh-button {
margin-top: .75rem;
.refresh-icon.refreshing {
animation: refresh-once .3s var(--tui-bezier);
}
}
}
.message-row {
gap: .5rem;
display: flex;
width: 100%;
align-items: stretch;
flex-direction: column;
&.user {
.message-header {
justify-content: flex-end;
}
.role-name {
order: 1;
}
.avatar {
order: 2;
}
}
.message-header {
gap: .5rem;
width: 100%;
display: flex;
align-items: center;
}
.avatar {
flex: none;
color: #0f766e;
width: 2rem;
height: 2rem;
display: flex;
background: #ccfbf1;
align-items: center;
justify-content: center;
}
&.user .avatar {
color: #2563eb;
background: #dbeafe;
}
.message-main {
width: 100%;
max-width: none;
}
&.user .message-main {
text-align: right;
}
.role-name {
color: var(--td-text-color-secondary);
margin: 0;
font-size: .6875rem;
}
.bubble {
color: var(--td-text-color-primary);
width: 100%;
padding: .75rem;
border-radius: .25rem 1rem 1rem 1rem;
background: var(--ai-bubble);
text-align: left;
box-shadow: 0 .25rem 1rem rgba(15, 23, 42, .05);
.thinking + .thinking {
margin-top: .75rem;
}
&.user {
color: white;
border-radius: 1rem .25rem 1rem 1rem;
background: #0f766e;
}
.content {
font-size: .875rem;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
.markdown-content {
white-space: normal;
:deep(.tui-markdown-view) {
width: auto;
font-size: .875rem;
line-height: 1.65;
word-break: break-word;
}
}
}
}
.typing {
gap: .25rem;
display: flex;
padding: .25rem .125rem;
align-items: center;
span {
width: .375rem;
height: .375rem;
border-radius: 50%;
background: #0f766e;
animation: typing 1.2s infinite ease-in-out;
&:nth-child(2) {
animation-delay: .16s;
}
&:nth-child(3) {
animation-delay: .32s;
}
}
}
.citation-list {
gap: .375rem;
margin-top: .75rem;
padding-top: .625rem;
display: flex;
border-top: 1px solid var(--td-component-border);
flex-direction: column;
.citation {
color: var(--td-text-color-secondary);
display: flex;
font-size: .6875rem;
line-height: 1.4;
flex-wrap: wrap;
}
.citation-label {
color: #0f766e;
margin-right: .25rem;
}
.citation-time {
width: 100%;
margin-top: .125rem;
}
}
.message-status {
color: var(--td-text-color-secondary);
margin-top: .5rem;
font-size: .6875rem;
line-height: 1.4;
}
}
@keyframes typing {
0%, 60%, 100% {
opacity: .35;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-.1875rem);
}
}
@keyframes refresh-once {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
</style>
+188
View File
@@ -0,0 +1,188 @@
<template>
<section class="ai-chat-panel" :aria-label="`${props.storeName || '当前门店'}经营助手`">
<ai-chat-message-list
ref="messageList"
:messages="messages"
:is-generating="isGenerating"
:quick-question-list="aiQuickQuestionList"
@select-question="submitMessage"
@refresh-questions="refreshQuickQuestions"
/>
<div class="bottom-area">
<div v-if="activeToolStatus" class="tool-status">
<t-icon name="loading" size="1rem" />
<span v-text="activeToolStatus" />
</div>
<ai-chat-composer
v-model="draft"
:is-generating="isGenerating"
:disabled="isLoadingConversation"
:last-error="lastError"
:can-retry="canRetry"
@send="submitMessage"
@stop="stopGeneration"
@retry="retryLastMessage"
/>
</div>
</section>
<ai-conversation-list
v-model:visible="conversationVisible"
:active-conversation-id="conversationId"
:disabled="isBusy"
:session-key="props.sessionKey"
@select="selectConversation"
@delete="handleConversationDelete"
/>
</template>
<script setup lang="ts">
import { useAiChat } from "@/composables/useAiChat";
import type { GaoAiConversation } from "@/api/GaoAiAPI";
import AiChatComposer from "@/pages/ai/components/AiChatComposer.vue";
import AiConversationList from "@/pages/ai/components/AiConversationList.vue";
import AiChatMessageList from "@/pages/ai/components/AiChatMessageList.vue";
import { aiQuickQuestionList, loadAiQuickQuestions, refreshAiQuickQuestions } from "@/pages/ai/aiChatQuestions";
import { useNavBarStore } from "@/store/navBarStore";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
defineOptions({
name: "AiChatPanel"
});
interface Props {
storeName: string;
sessionKey: string;
storeId?: string;
}
const props = defineProps<Props>();
const navBarStore = useNavBarStore();
type MessageListExpose = {
scrollToBottom: () => Promise<void>;
};
const messageListRef = useTemplateRef<MessageListExpose>("messageList");
const draft = shallowRef("");
const conversationVisible = ref(false);
const {
messages,
conversationId,
isGenerating,
isLoadingConversation,
activeToolStatus,
lastError,
canRetry,
sendMessage,
loadConversation,
stopGeneration,
retryLastMessage,
resetConversation
} = useAiChat({
sessionKey: () => props.sessionKey,
storeId: () => props.storeId,
scrollToBottom
});
const isBusy = computed(() => isGenerating.value || isLoadingConversation.value);
async function submitMessage(message: string): Promise<void> {
await sendMessage(message);
}
function openConversationList(): void {
if (!isBusy.value) {
conversationVisible.value = true;
}
}
async function selectConversation(conversation: GaoAiConversation): Promise<void> {
if (isBusy.value || !conversation.id) {
return;
}
try {
await loadConversation(conversation.id);
} catch (error) {
Message.error(getErrorMessage(error, "历史会话读取失败"));
}
}
function handleConversationDelete(deletedConversationId: string): void {
if (deletedConversationId === conversationId.value) {
resetConversation();
}
}
function refreshQuickQuestions(): void {
refreshAiQuickQuestions();
}
async function scrollToBottom(): Promise<void> {
await messageListRef.value?.scrollToBottom();
}
onMounted(async () => {
navBarStore.clearRight();
navBarStore.setRightText("历史", "var(--td-brand-color)", openConversationList);
await loadAiQuickQuestions();
});
watch(isBusy, value => navBarStore.setRightDisabled(value));
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.ai-chat-panel {
height: 100%;
display: flex;
position: relative;
overflow: hidden;
min-height: 0;
background: var(--ai-background);
padding-top: var(--app-nav-height);
flex-direction: column;
.bottom-area {
inset: auto 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
width: auto;
margin: 0 auto;
z-index: 2;
position: absolute;
max-width: 40rem;
.tool-status {
gap: .375rem;
margin-bottom: .5rem;
color: #0f766e;
padding: .5rem .75rem;
border-radius: .75rem;
display: flex;
font-size: .75rem;
background: var(--ai-soft);
align-items: center;
.t-icon {
animation: rotate 1s linear infinite;
}
}
}
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
:global(:root) {
--ai-background: rgba(255, 255, 255, .98);
--ai-bubble: #f5f6f8;
--ai-input: white;
--ai-soft: #f0fdfa;
}
:global(html.theme-dark) {
--ai-background: rgba(15, 23, 42, .98);
--ai-bubble: #1e293b;
--ai-input: #111827;
--ai-soft: rgba(20, 184, 166, .12);
}
</style>
@@ -0,0 +1,84 @@
<template>
<section class="thinking" aria-label="Agent think">
<div class="thinking-header">
<span>小糕正在思考...</span>
</div>
<div
ref="thinkingContent"
class="thinking-content"
role="log"
aria-live="polite"
>
<span v-text="props.content" />
</div>
</section>
</template>
<script setup lang="ts">
interface Props {
content: string;
isGenerating: boolean;
}
const props = defineProps<Props>();
const thinkingContentRef = useTemplateRef<HTMLElement>("thinkingContent");
// ---------- 自动滚动 ----------
async function scrollToBottom(): Promise<void> {
await nextTick();
const element = thinkingContentRef.value;
if (!element) {
return;
}
element.scrollTop = element.scrollHeight;
}
watch(
[() => props.content, () => props.isGenerating],
async ([content, isGenerating]) => {
if (content && isGenerating) {
await scrollToBottom();
}
},
{ deep: true, flush: "post" }
);
</script>
<style scoped lang="less">
.thinking {
gap: .5rem;
height: clamp(2rem, 24vh, 10rem);
display: flex;
padding: .625rem .75rem;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
background: var(--ai-background);
flex-direction: column;
min-height: 2rem;
max-height: 10rem;
overflow: hidden;
.thinking-header {
gap: .375rem;
display: flex;
align-items: center;
color: var(--td-text-color-secondary);
font-size: .75rem;
line-height: 1rem;
}
.thinking-content {
flex: 1;
padding-right: .25rem;
color: var(--td-text-color-secondary);
font-size: .75rem;
line-height: 1.5;
min-height: 0;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
}
</style>
@@ -0,0 +1,356 @@
<template>
<t-popup v-model="popupVisible" placement="right" destroy-on-close>
<section class="ai-conversation-list" @scroll="handleScroll">
<header class="header glass-white">
<h3>历史会话</h3>
<t-button
theme="danger"
shape="circle"
variant="text"
@click="popupVisible = false"
>
<template #icon>
<t-icon name="close" />
</template>
</t-button>
</header>
<t-cell-group v-if="conversationList.length" class="items fix-last-cell-line">
<t-swipe-cell
v-for="(item, index) in conversationList"
:key="item.id"
class="item"
v-entrance-transition="index * 30"
>
<t-cell
class="cell"
:class="{ active: item.id === props.activeConversationId }"
:disabled="props.disabled"
@click="selectConversation(item)"
>
<template #leftIcon>
<div class="icon">
<t-icon name="chat" size="1.25rem" />
</div>
</template>
<template #title>
<span class="title" v-text="getConversationName(item)" />
</template>
<template #description>
<pass-time-label :timestamp="formatConversationTime(item)" underline />
</template>
</t-cell>
<template #right>
<t-button
class="swipe-cell-button"
theme="danger"
:loading="deletingId === item.id"
:disabled="props.disabled"
@click.stop="confirmDelete(item)"
>
删除
</t-button>
</template>
</t-swipe-cell>
</t-cell-group>
<t-empty v-else-if="!isLoading" class="center-tips" description="暂无历史会话" />
<t-loading
v-if="isLoading && !conversationList.length"
class="center-tips"
size="1.5rem"
text="正在加载会话"
/>
<div class="footer glass-white">
<span class="llm gray">Deepseek v4.1-flash</span>
<t-button
class="agreement-button"
variant="text"
size="small"
type="button"
content="服务协议"
/>
</div>
</section>
</t-popup>
</template>
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { deleteConversation as removeConversation, type GaoAiConversation, listConversations } from "@/api/GaoAiAPI";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { PassTimeLabel } from "timi-web";
defineOptions({
name: "AiConversationList"
});
interface Props {
visible: boolean;
activeConversationId: string;
disabled: boolean;
sessionKey: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
"update:visible": [value: boolean];
select: [conversation: GaoAiConversation];
delete: [conversationId: string];
}>();
const pageSize = 20;
const popupVisible = computed({
get() {
return props.visible;
},
set(value: boolean) {
emit("update:visible", value);
}
});
const conversationList = ref<GaoAiConversation[]>([]);
const isLoading = ref(false);
const isNoMore = ref(false);
const lastId = ref("");
const deletingId = ref("");
let requestToken = 0;
let loadingPromise: Promise<void> | undefined;
async function loadMore(): Promise<void> {
if (loadingPromise) {
await loadingPromise;
return;
}
if (isNoMore.value) {
return;
}
const currentToken = requestToken;
const request = (async () => {
isLoading.value = true;
try {
const result = await listConversations({
lastId: lastId.value || undefined,
limit: pageSize,
sortBy: "-updated_at"
});
if (currentToken !== requestToken) {
return;
}
const nextList = (result.data || []).filter(item => item.id);
const idSet = new Set(conversationList.value.map(item => item.id));
conversationList.value = conversationList.value.concat(nextList.filter(item => !idSet.has(item.id)));
lastId.value = nextList[nextList.length - 1]?.id || lastId.value;
isNoMore.value = result["has_more"] !== true || !nextList.length;
} catch (error) {
if (currentToken === requestToken) {
Message.error(getErrorMessage(error, "历史会话加载失败"));
}
} finally {
if (currentToken === requestToken) {
isLoading.value = false;
}
}
})();
loadingPromise = request;
try {
await request;
} finally {
if (loadingPromise === request) {
loadingPromise = undefined;
}
}
}
async function reloadList(): Promise<void> {
const currentToken = requestToken + 1;
requestToken = currentToken;
conversationList.value = [];
lastId.value = "";
isNoMore.value = false;
if (loadingPromise) {
await loadingPromise;
}
if (currentToken === requestToken) {
await loadMore();
}
}
async function handleScroll(event: Event): Promise<void> {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.scrollHeight - target.scrollTop - target.clientHeight < 40) {
await loadMore();
}
}
function selectConversation(conversation: GaoAiConversation): void {
if (props.disabled || !conversation.id) {
return;
}
emit("select", conversation);
emit("update:visible", false);
}
async function confirmDelete(conversation: GaoAiConversation): Promise<void> {
if (props.disabled || deletingId.value || !conversation.id) {
return;
}
const conversationId = conversation.id;
const dialog = DialogPlugin.confirm({
title: "删除历史会话",
content: "确定删除这段历史会话吗?删除后无法恢复。",
cancelBtn: "取消",
confirmBtn: "删除",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await deleteItem(conversationId);
}
});
}
async function deleteItem(conversationId: string): Promise<void> {
deletingId.value = conversationId;
try {
await removeConversation(conversationId);
emit("delete", conversationId);
await reloadList();
Message.success("历史会话已删除");
} catch (error) {
Message.error(getErrorMessage(error, "历史会话删除失败"));
} finally {
deletingId.value = "";
}
}
function getConversationName(conversation: GaoAiConversation): string {
return conversation.name?.trim() || conversation.introduction?.trim() || "未命名会话";
}
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 timestamp < 100000000000 ? timestamp * 1000 : timestamp;
}
watch(() => props.visible, async visible => {
if (visible) {
await reloadList();
}
});
watch(() => props.sessionKey, async () => {
requestToken += 1;
conversationList.value = [];
lastId.value = "";
isNoMore.value = false;
if (props.visible) {
await reloadList();
}
});
</script>
<style scoped lang="less">
.ai-conversation-list {
width: min(70vw, 24rem);
height: 100vh;
padding: calc(var(--app-nav-height) + .5rem) 0 calc(1rem + env(safe-area-inset-bottom, 0px));
display: flex;
overflow: auto;
position: relative;
flex-direction: column;
.header {
top: 0;
z-index: 1;
display: flex;
position: sticky;
box-shadow: var(--td-shadow-1);
align-items: center;
justify-content: space-between;
h3 {
margin: .5rem 0 .5rem 1rem;
}
.t-button {
gap: .25rem;
display: inline-flex;
align-items: center;
}
}
.items {
.item {
.cell {
&.active {
background: var(--td-bg-color-secondarycontainer);
}
:deep(.t-cell__title) {
min-width: 0;
}
.icon {
color: #0f766e;
width: 2rem;
height: 2rem;
border-radius: .625rem;
display: flex;
background: #ccfbf1;
align-items: center;
justify-content: center;
}
.title {
overflow: hidden;
display: block;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.delete {
height: 100%;
}
}
}
.footer {
bottom: 0;
display: flex;
padding: 0 .5rem;
position: sticky;
box-shadow: 0 -2px 10px rgba(0, 0, 0, .03);
align-items: center;
justify-content: space-between;
.llm {
font-size: .875rem;
}
.agreement-button {
}
}
}
:global(html.theme-dark) {
.conversation-drawer {
background: #0f172a;
.icon {
color: #5eead4;
background: rgba(20, 184, 166, .15);
}
}
}
</style>
+40 -26
View File
@@ -98,32 +98,39 @@
<t-input v-model.trim="formData.telephone" type="tel" placeholder="手机号" borderless :disabled="isCustomerBound" />
</t-form-item>
<t-form-item label="介绍人">
<div class="referrer">
<div class="selector">
<t-input
:value="formData.introducerCustomer?.name"
readonly
placeholder="选择介绍人"
borderless
:disabled="isCustomerBound"
/>
<div class="selector">
<t-input
:value="formData.introducerCustomer?.name"
readonly
placeholder="选择介绍人"
borderless
:disabled="isCustomerBound"
/>
<div class="actions">
<t-button
theme="primary"
variant="outline"
v-if="introducerCustomerId"
variant="text"
theme="danger"
size="extra-small"
:disabled="isCustomerBound"
@click="selectorVisible = true"
>选择客户</t-button>
</div>
@click="clearIntroducer"
>清除</t-button>
<t-button
theme="primary"
variant="outline"
size="extra-small"
:disabled="isCustomerBound"
@click="selectorVisible = true"
>选择客户</t-button>
</div>
<customer-profile-card
v-if="formData.introducerCustomer"
class="profile"
:customer="formData.introducerCustomer"
/>
</div>
</t-form-item>
<t-form-item v-if="formData.introducerCustomer" label-width="0">
<customer-profile-card
class="introducer-profile"
:customer="formData.introducerCustomer"
/>
</t-form-item>
<t-form-item label="调理部位" :arrow="!isCustomerBound" @click="openBodyAreaPicker">
<div class="picker-text" v-text="selectedAreaText" />
</t-form-item>
@@ -332,6 +339,15 @@ async function handleIntroducerChange(id: string): Promise<void> {
formData.introducerCustomer = customer;
}
function clearIntroducer(): void {
if (isCustomerBound.value) {
return;
}
introducerCustomerId.value = "";
formData.introducerCustomerId = "";
formData.introducerCustomer = undefined;
}
// ---------- 表单 ----------
const formData = reactive<Customer>({
@@ -743,15 +759,9 @@ function buildAttachmentList(): Attachment[] {
text-overflow: ellipsis;
}
.referrer {
.selector {
gap: .75rem;
display: flex;
flex-direction: column;
.selector {
gap: .75rem;
display: flex;
}
.actions {
gap: .5rem;
@@ -760,6 +770,10 @@ function buildAttachmentList(): Attachment[] {
}
}
.introducer-profile {
flex: 1;
}
.sex {
gap: .5rem;
display: flex;
+3 -49
View File
@@ -168,6 +168,7 @@ import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { NavBarAction } from "@/types/NavBarAction";
import type { Tag } from "@/types/Tag";
import { buildPresetDateRange, parseDateTime } from "@/utils/date";
import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
@@ -206,7 +207,7 @@ const dateOptions: Array<{ label: string; value: DateFilterType }> = [
{ label: "自定义范围", value: "custom" }
];
const dateFilterType = ref<DateFilterType>("all");
const dateRange = ref<[string, string]>(buildPresetRange("all"));
const dateRange = ref<[string, string]>(buildPresetDateRange("all"));
const startDraft = ref("");
const endDraft = ref("");
const startVisible = ref(false);
@@ -279,7 +280,7 @@ async function selectDateFilter(value: DropdownValue): Promise<void> {
dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
dateRange.value = buildPresetDateRange(dateFilterType.value);
await refresh();
}
}
@@ -499,53 +500,6 @@ function buildCreatedAtRange(): [number | undefined, number | undefined] {
];
}
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = new Date();
current.setHours(0, 0, 0, 0);
const day = current.getDay() || 7;
const thisWeekMonday = new Date(current);
thisWeekMonday.setDate(current.getDate() - day + 1);
const lastWeekMonday = new Date(thisWeekMonday);
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
const lastWeekSunday = new Date(thisWeekMonday);
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
}
if (type === "thisMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
}
if (type === "lastMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
const end = new Date(current.getFullYear(), current.getMonth(), 0);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
today: 0,
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
return [`${Time.formatDate(date)} 00:00`, `${Time.formatDate(date)} 23:59`];
}
function parseDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
</script>
<style scoped lang="less">
+1 -1
View File
@@ -9,7 +9,7 @@
@refresh="refresh"
@scrolltolower="fetch"
>
<t-cell-group theme="card">
<t-cell-group class="fix-last-cell-line" theme="card">
<t-swipe-cell
v-entrance-transition="index * 30"
v-for="(item, index) in visibleEventList"
+11 -60
View File
@@ -73,7 +73,8 @@
/>
<t-cell title="事件" :note="eventFilterLabel" />
<t-cell v-if="optionFilterLabel !== '全部选项'" title="选项" :note="optionFilterLabel" />
<t-cell title="数据量" :note="`共 ${pageResult.total} 条记录`" />
<t-cell title="记录数" :note="Text.display(pageResult.total)" />
<t-cell title="客户数" :note="Text.display(pageResult.customerCount)" />
<t-cell v-if="summaryNumberValueText" title="数值合计" :note="summaryNumberValueText" />
</t-cell-group>
<t-cell-group v-if="hasSelectedOption && optionStatList.length" title="选项统计" class="option-stat" theme="card">
@@ -136,7 +137,7 @@
<script setup lang="ts">
import type { DropdownValue } from "tdesign-mobile-vue";
import { Time, WebToolkit } from "timi-web";
import { Time, Text, WebToolkit } from "timi-web";
import { useUserStore } from "timi-web-api";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
@@ -148,6 +149,7 @@ import type { EventOptionStat, EventRecordPage, EventRecordPageResult, Verificat
import type { NavBarAction } from "@/types/NavBarAction";
import { isRetiredEvent } from "@/utils/event";
import { formatEventNumberValue } from "@/utils/eventValue";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
import { permissionCodes } from "@/utils/permission";
import { Message } from "@/utils/message.ts";
@@ -178,7 +180,7 @@ const dateOptions: Array<{ label: string; value: DateFilterType }> = [
const keyword = ref(getInitialKeyword());
const customerId = ref(getQueryText("customerId"));
const customerSelectorVisible = ref(false);
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType, dateOptions, "today"));
const dateRange = ref(parseDateRange(dateFilterType.value));
const startDraft = ref("");
const endDraft = ref("");
@@ -364,7 +366,8 @@ const page = reactive<EventRecordPage>({
const pageResult = reactive<EventRecordPageResult>({
total: 0,
list: [],
numberValueTotal: null
numberValueTotal: null,
customerCount: 0
});
const optionStatList = ref<EventOptionStat[]>([]);
let loadToken = 0;
@@ -389,6 +392,7 @@ async function reloadRecords(): Promise<void> {
pageResult.total = 0;
pageResult.list = [];
pageResult.numberValueTotal = null;
pageResult.customerCount = 0;
optionStatList.value = [];
isNoMore.value = false;
await Promise.all([loadRecords(), loadOptionStats()]);
@@ -420,6 +424,7 @@ async function loadRecords(): Promise<void> {
pageResult.list = pageResult.list.concat(result.list || []);
pageResult.total = result.total || 0;
pageResult.numberValueTotal = result.numberValueTotal ?? null;
pageResult.customerCount = result.customerCount ?? 0;
page.index += 1;
isNoMore.value = pageResult.list.length >= pageResult.total || (result.list || []).length < page.size;
} finally {
@@ -471,7 +476,7 @@ async function selectDateFilter(type: DropdownValue): Promise<void> {
dateFilterType.value = String(type) as DateFilterType;
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
dateRange.value = buildPresetDateRange(dateFilterType.value);
await replaceFilterQuery();
await reloadRecords();
} else {
@@ -586,14 +591,9 @@ function getQueryText(key: string): string {
return Array.isArray(value) ? String(value[0] || "") : String(value || "");
}
function parseDateFilterType(value: unknown): DateFilterType {
const text = Array.isArray(value) ? String(value[0] || "") : String(value || "");
return dateOptions.some(item => item.value === text) ? text as DateFilterType : "today";
}
function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") {
return buildPresetRange(type);
return buildPresetDateRange(type);
}
const beginAt = getQueryText("beginAt");
@@ -657,45 +657,6 @@ async function replaceFilterQuery(): Promise<void> {
});
}
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = new Date();
current.setHours(0, 0, 0, 0);
const day = current.getDay() || 7;
const thisWeekMonday = new Date(current);
thisWeekMonday.setDate(current.getDate() - day + 1);
const lastWeekMonday = new Date(thisWeekMonday);
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
const lastWeekSunday = new Date(thisWeekMonday);
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
}
if (type === "thisMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
}
if (type === "lastMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
const end = new Date(current.getFullYear(), current.getMonth(), 0);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
today: 0,
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
const value = `${Time.formatDate(date)} 00:00`;
return [value, `${Time.formatDate(date)} 23:59`];
}
function buildUnixRange(): [number, number] {
if (dateFilterType.value === "all") {
return [0, Time.toTimestamp(new Date())];
@@ -707,16 +668,6 @@ function buildUnixRange(): [number, number] {
];
}
function parseDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
onMounted(async () => {
navBarStore.clearRight();
navBarStore.setCustomRightVisible(canShowActionMenu.value);
+5 -51
View File
@@ -132,6 +132,7 @@ import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import type { CustomerEventRankView, EventRecordRankPage, EventRecordRankType } from "@/types/EventRecord";
import type { EventItem } from "@/types/EventItem";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
import { getErrorMessage } from "@/utils/error";
import { formatEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
@@ -164,7 +165,7 @@ const dateOptions: Array<{ label: string; value: DateFilterType }> = [
// ---------- 筛选 ----------
const rankType = ref<EventRecordRankType>(parseRankType(route.query.rankType));
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType, dateOptions, "all"));
const dateRange = ref(parseDateRange(dateFilterType.value));
const eventFilter = ref<string[]>([]);
const eventList = ref<EventItem[]>([]);
@@ -341,9 +342,9 @@ async function selectDateFilter(value: DropdownValue): Promise<void> {
return;
}
dateFilterType.value = parseDateFilterType(value);
dateFilterType.value = parseDateFilterType(value, dateOptions, "all");
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
dateRange.value = buildPresetDateRange(dateFilterType.value);
await replaceFilterQuery();
await reloadRanks();
return;
@@ -388,14 +389,9 @@ function parseRankType(value: unknown): EventRecordRankType {
return rankTypeOptions.some(item => item.value === text) ? text as EventRecordRankType : "TOTAL_DAYS";
}
function parseDateFilterType(value: unknown): DateFilterType {
const text = Array.isArray(value) ? String(value[0] || "") : String(value || "");
return dateOptions.some(item => item.value === text) ? text as DateFilterType : "all";
}
function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") {
return buildPresetRange(type);
return buildPresetDateRange(type);
}
return [getQueryText("beginAt"), getQueryText("endAt")];
@@ -436,38 +432,6 @@ async function replaceFilterQuery(): Promise<void> {
});
}
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = new Date();
current.setHours(0, 0, 0, 0);
const day = current.getDay() || 7;
const thisWeekMonday = new Date(current);
thisWeekMonday.setDate(current.getDate() - day + 1);
const lastWeekMonday = new Date(thisWeekMonday);
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
const lastWeekSunday = new Date(thisWeekMonday);
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
}
if (type === "thisMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth">, number> = {
today: 0,
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
return [`${Time.formatDate(date)} 00:00`, `${Time.formatDate(date)} 23:59`];
}
function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") {
return [undefined, undefined];
@@ -478,16 +442,6 @@ function buildUnixRange(): [number | undefined, number | undefined] {
];
}
function parseDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
onMounted(async () => {
await loadEvents();
initialized = true;
+156 -36
View File
@@ -26,10 +26,7 @@
<div class="title">
<div class="heading">
<div class="name-gift">
<strong
class="clip-text"
v-text="item.productNameSnapshot || '未命名产品'"
/>
<strong v-text="item.productNameSnapshot || '未命名产品'"/>
<t-tag
v-if="item.gifted"
size="small"
@@ -39,17 +36,12 @@
</div>
<span class="can-fulfill" v-text="getItemDescription(item)" />
</div>
</div>
<div class="input">
<div
v-if="item.skuCodeSnapshot || item.skuSpecSnapshot?.length || item.skuNameSnapshot || item.specSummarySnapshot || item.unitSnapshot"
class="sku-tags"
>
<t-tag
v-if="item.skuCodeSnapshot"
size="small"
theme="primary"
variant="light"
:content="item.skuCodeSnapshot"
/>
<product-sku-snapshot-tags
:product-name="item.productNameSnapshot"
:snapshot="item.skuSpecSnapshot"
@@ -57,8 +49,6 @@
:unit="item.unitSnapshot"
/>
</div>
</div>
<div class="input">
<t-input
v-model.trim="quantityMap[item.id || '']"
class="quantity"
@@ -80,16 +70,16 @@
<t-button
size="extra-small"
variant="outline"
:disabled="getAvailableBaseQuantity(item) === '0'"
@click.stop="fillAllQuantity(item)"
>全部履约</t-button>
:disabled="getAvailableBaseQuantity(item) === '0' || (directVerify && isStockChecking)"
@click.stop="fillAllQuantity(item)"
>全部履约</t-button>
</div>
</div>
</div>
</t-form-item>
</t-form>
<div v-else class="empty" v-text="'暂无可履约明细,请返回订单详情检查'" />
<t-form class="form" label-align="left" label-width="6rem">
<t-form class="form" label-width="7rem">
<t-form-item label="履约时间" content-align="right">
<t-button
theme="primary"
@@ -99,6 +89,15 @@
@click="pickerVisible = true"
/>
</t-form-item>
<t-form-item v-if="canDirectVerify" label="直接核销履约" content-align="right">
<t-switch v-model="directVerify" size="small" :disabled="isSaving || isStockChecking" />
</t-form-item>
<t-form-item v-if="directVerify" label-width="0">
<div class="stock-tips">
<span class="tips red">直接核销会立即扣减实物库存</span>
<product-stock-preview v-if="stockPreviewList.length" :items="stockPreviewList" />
</div>
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model="remark" borderless autosize maxlength="500" />
</t-form-item>
@@ -129,17 +128,31 @@ import FulfillmentAPI from "@/api/FulfillmentAPI";
import ProductAPI from "@/api/ProductAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import ProductSkuSnapshotTags from "@/components/ProductSkuSnapshotTags.vue";
import ProductStockPreview from "@/components/ProductStockPreview.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { Attachment } from "timi-web-api";
import { useUserStore } from "timi-web-api";
import type { GaoOrderFulfillmentItemRequest } from "@/types/Fulfillment";
import type { GaoSalesOrderItem, SalesOrder } from "@/types/SalesOrder";
import type { ProductSku, ProductSkuUnit } from "@/types/Product";
import type { PhotoItem } from "@/types/Photo";
import type { Attachment } from "timi-web-api";
import { comparePackageQuantity, convertPackageQuantity, createPackageRequestNo, getPackageQuantityText, normalizePackageQuantity, toPackageBaseQuantity } from "@/utils/package";
import {
comparePackageQuantity,
convertPackageQuantity,
createPackageRequestNo,
getPackageQuantityText,
normalizePackageQuantity,
toPackageBaseQuantity
} from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import type { ProductStockPreviewItem } from "@/utils/productStock";
import {
buildProductStockPreview,
getProductStockShortageMessage,
mergeProductStockRequirements
} from "@/utils/productStock";
import { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL } from "@/utils/product";
@@ -162,6 +175,10 @@ const photoItems = ref<PhotoItem[]>([]);
const unitPickerVisible = ref(false);
const unitPickerSourceItemId = ref("");
const unitPickerDraft = ref<Array<string | number>>([]);
const directVerify = ref(false);
const isStockChecking = ref(false);
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
let stockCheckToken = 0;
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
const isPackage = computed(() => order.value?.orderType === "PACKAGE");
const expectedItemType = computed(() => isPackage.value ? "PACKAGE_ENTITLEMENT" : "DIRECT_PRODUCT");
@@ -172,6 +189,29 @@ const unitPickerOptions = computed(() => {
return item ? getFulfillmentUnitList(item).map(unit => ({ label: unit.name, value: unit.id || "" })) : [];
});
const canFulfillByPayment = computed(() => order.value?.paymentStatus === "PAID" || userStore.hasRoles(gaoRoleCodes.storeManager));
const canDirectVerify = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager)
&& userStore.hasPermissions(permissionCodes.orderFulfillment.create)
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
&& userStore.hasPermissions(permissionCodes.productStock.read));
watch(directVerify, async value => {
stockCheckToken += 1;
const token = stockCheckToken;
if (!value) {
isStockChecking.value = false;
return;
}
isStockChecking.value = true;
try {
if (!await validateDirectStock() && token === stockCheckToken && directVerify.value) {
directVerify.value = false;
}
} finally {
if (token === stockCheckToken) {
isStockChecking.value = false;
}
}
});
async function loadData(): Promise<void> {
const salesOrderId = String(route.query.salesOrderId || "");
@@ -250,7 +290,7 @@ function getFulfillmentUnitForAvailableQuantity(item: GaoSalesOrderItem, unit?:
return getBaseUnit(item) || unit;
}
function fillAllQuantity(item: GaoSalesOrderItem): void {
async function fillAllQuantity(item: GaoSalesOrderItem): Promise<void> {
const unit = getFulfillmentUnit(item);
if (!item.id || !unit) {
Message.warning("请先选择实际履约单位");
@@ -262,6 +302,9 @@ function fillAllQuantity(item: GaoSalesOrderItem): void {
return;
}
quantityMap[item.id] = quantity;
if (directVerify.value) {
await previewDirectStock();
}
}
function getFulfillmentUnitList(item: GaoSalesOrderItem): ProductSkuUnit[] {
@@ -343,6 +386,76 @@ function buildAttachments(): Partial<Attachment>[] {
}] : []);
}
async function validateDirectStock(): Promise<boolean> {
stockPreviewList.value = [];
const selected = sourceItems.value.filter(item => !!item.id && quantityMap[item.id]);
const skuMap = new Map<string, ProductSku>();
for (const item of selected) {
if (!item.skuId) {
continue;
}
let sku = fulfillmentSkuMap[item.skuId];
if (!sku) {
try {
sku = await ProductAPI.skuDetail(item.skuId);
} catch {
// 统一在下方提示,避免部分 SKU 查询失败时静默跳过库存校验
}
}
if (!sku) {
Message.warning(`${item.productNameSnapshot || "产品"} 的库存信息加载失败,请刷新后重试`);
return false;
}
fulfillmentSkuMap[item.skuId] = sku;
skuMap.set(item.skuId, sku);
}
const requirementList = mergeProductStockRequirements(selected.flatMap(item => {
const sku = item.skuId ? skuMap.get(item.skuId) : undefined;
const unit = getFulfillmentUnit(item);
const quantity = normalizePackageQuantity(quantityMap[item.id || ""] || "");
const baseQuantity = quantity && unit ? toPackageBaseQuantity(quantity, unit.baseQuantityPerUnit) : undefined;
if (!item.skuId || !sku?.stockEnabled || !baseQuantity) {
return [];
}
return [{ skuId: item.skuId, baseQuantity, productName: item.productNameSnapshot || "未命名产品", unitName: "基础单位", stockEnabled: sku.stockEnabled }];
}));
if (!requirementList.length) {
return true;
}
try {
const stockList = await ProductAPI.stockBatchDetail({ skuIdList: requirementList.map(item => item.skuId) });
stockPreviewList.value = buildProductStockPreview(stockList, requirementList);
const shortageMessage = getProductStockShortageMessage(stockList, requirementList);
if (shortageMessage) {
Message.warning(`${shortageMessage},不能直接核销履约`);
return false;
}
return true;
} catch (error) {
stockPreviewList.value = [];
Message.error(getErrorMessage(error, "库存查询失败,暂不能直接核销履约"));
return false;
}
}
async function previewDirectStock(): Promise<void> {
if (!directVerify.value || isStockChecking.value) {
return;
}
stockCheckToken += 1;
const token = stockCheckToken;
isStockChecking.value = true;
try {
if (!await validateDirectStock() && token === stockCheckToken && directVerify.value) {
directVerify.value = false;
}
} finally {
if (token === stockCheckToken) {
isStockChecking.value = false;
}
}
}
function validate(): boolean {
if (!order.value?.id) {
Message.warning("请从订单详情进入履约创建");
@@ -386,7 +499,12 @@ function validate(): boolean {
}
async function save(): Promise<void> {
if (isSaving.value || !validate()) return;
if (isSaving.value) return;
if (isStockChecking.value) {
Message.warning("库存正在预检,请稍候再提交");
return;
}
if (!validate()) return;
if (!requestNo.value) requestNo.value = createPackageRequestNo();
isSaving.value = true;
try {
@@ -397,15 +515,19 @@ async function save(): Promise<void> {
skuUnitId: fulfillmentUnitMap[item.id!]!,
quantity: normalizePackageQuantity(quantityMap[item.id || ""])!
}));
const result = await FulfillmentAPI.create({
const request = {
salesOrderId: order.value!.id!,
requestNo: requestNo.value,
deliveredAt: resolveTimestamp(deliveredAtText.value),
itemList,
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("履约单已提交");
};
if (directVerify.value && !await validateDirectStock()) return;
const result = directVerify.value
? await FulfillmentAPI.createAndVerify(request)
: await FulfillmentAPI.create(request);
Message.success(directVerify.value ? "履约单创建并核销成功" : "履约单已提交");
await router.replace(`/management/order-fulfillments/${result.id}`);
} catch (error) {
Message.error(getErrorMessage(error, "履约单创建失败,重试会复用同一请求号"));
@@ -487,10 +609,10 @@ onUnmounted(() => navBarStore.clearRight());
min-width: 0;
display: flex;
overflow: hidden;
align-items: center;
justify-content: start;
strong {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@@ -503,6 +625,12 @@ onUnmounted(() => navBarStore.clearRight());
white-space: nowrap;
}
}
}
.input {
gap: .5rem;
display: flex;
margin-top: .5rem;
.sku-tags {
gap: .375rem;
@@ -510,12 +638,6 @@ onUnmounted(() => navBarStore.clearRight());
flex-wrap: wrap;
align-items: center;
}
}
.input {
gap: .5rem;
display: flex;
margin-top: .5rem;
.unit {
font-size: .875rem;
@@ -526,10 +648,8 @@ onUnmounted(() => navBarStore.clearRight());
}
}
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
.tips {
font-size: .875rem;
}
}
@@ -62,7 +62,7 @@
<t-cell v-if="!fulfillment.itemList?.length" title="暂无履约明细" />
</t-cell-group>
<section
v-if="(!isOrderCancelled && isWaitingStock && canVerify) || (!isOrderCancelled && isPending && (canVerify || canCreate)) || (isVerified && canRevoke)"
v-if="(!isOrderCancelled && isWaitingStock && (canVerify || canCreate)) || (!isOrderCancelled && isPending && (canVerify || canCreate)) || (isVerified && canRevoke)"
class="actions"
v-entrance-transition="140"
>
@@ -77,7 +77,7 @@
<t-button theme="danger" content="拒绝履约" @click="openReason('reject')" />
</div>
<t-button
v-if="!isOrderCancelled && isPending && canCreate"
v-if="!isOrderCancelled && (isWaitingStock || isPending) && canCreate"
theme="default"
content="撤销履约"
@click="openReason('cancel')"
+4 -57
View File
@@ -142,6 +142,7 @@ import type { GaoFulfillmentStatus, OrderFulfillment } from "@/types/Fulfillment
import type { NavBarAction } from "@/types/NavBarAction";
import type { Customer } from "@/types/Customer";
import type { SalesOrder } from "@/types/SalesOrder";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
@@ -186,7 +187,7 @@ const selectedCustomer = ref<Customer>();
const salesOrder = ref<SalesOrder>();
const customerSelectorVisible = ref(false);
const customerSearchKeyword = ref("");
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
const dateFilterType = ref<DateFilterType>(parseDateFilterType(getQueryText(route.query.dateType), dateOptions, "today"));
const dateRange = ref<[string, string]>(parseDateRange(dateFilterType.value));
const startDraft = ref("");
const endDraft = ref("");
@@ -224,14 +225,9 @@ function getInitialStatus(value: unknown): "ALL" | GaoFulfillmentStatus {
return "ALL";
}
function parseDateFilterType(value: unknown): DateFilterType {
const dateFilter = getQueryText(value);
return dateOptions.some(item => item.value === dateFilter) ? dateFilter as DateFilterType : "today";
}
function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") {
return buildPresetRange(type);
return buildPresetDateRange(type);
}
return [getQueryText(route.query.beginAt), getQueryText(route.query.endAt)];
}
@@ -368,7 +364,7 @@ async function selectDateFilter(value: DropdownValue): Promise<void> {
}
dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
dateRange.value = buildPresetDateRange(dateFilterType.value);
await replaceFilterQuery();
await reloadList();
} else {
@@ -444,45 +440,6 @@ function isValidTransitionDepth(value: string): boolean {
return value !== "" && Number.isInteger(depth) && 0 <= depth && depth <= 20;
}
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = new Date();
current.setHours(0, 0, 0, 0);
const day = current.getDay() || 7;
const thisWeekMonday = new Date(current);
thisWeekMonday.setDate(current.getDate() - day + 1);
const lastWeekMonday = new Date(thisWeekMonday);
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
const lastWeekSunday = new Date(thisWeekMonday);
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
}
if (type === "thisMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
}
if (type === "lastMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
const end = new Date(current.getFullYear(), current.getMonth(), 0);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
today: 0,
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
const value = `${Time.formatDate(date)} 00:00`;
return [value, `${Time.formatDate(date)} 23:59`];
}
function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") {
return [0, Time.toTimestamp(new Date())];
@@ -493,16 +450,6 @@ function buildUnixRange(): [number | undefined, number | undefined] {
];
}
function parseDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
async function toDetail(item: OrderFulfillment): Promise<void> {
if (item.id) {
await router.push({
+27 -19
View File
@@ -50,6 +50,7 @@
placeholder="请选择产品"
required-mark
@update:model-value="applyProduct(index, $event)"
@select="applyProductSelection(index, $event)"
/>
<t-form-item v-if="item.skuId" label="SKU 单位" content-align="right">
<span v-text="getUnit(item)?.name || '未选择单位'" />
@@ -111,7 +112,7 @@ import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
import ProductAPI from "@/api/ProductAPI";
import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
import type { Product, ProductSelection, ProductSku, ProductSkuUnit } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { Message } from "@/utils/message";
@@ -204,23 +205,27 @@ const estimatedOriginalAmount = computed<number | undefined>(() => {
});
const estimatedOriginalAmountText = computed(() => estimatedOriginalAmount.value === undefined ? "待完善" : `¥${formatProductAmount(estimatedOriginalAmount.value)}`);
async function applyProduct(index: number, value?: { skuId: string; skuUnitId: string }): Promise<void> {
function applyProduct(index: number, value?: { skuId: string; skuUnitId: string }): void {
if (!value) {
return;
}
const sku = skuCache.get(value.skuId) || await ProductAPI.skuDetail(value.skuId);
skuCache.set(value.skuId, sku);
const product = productCache.get(sku.productId || "") || await ProductAPI.detail(sku.productId || "");
productCache.set(sku.productId || "", product);
const unit = (sku.unitList || []).find(item => item.id === value.skuUnitId);
Object.assign(form.itemList[index], {
skuId: value.skuId,
skuUnitId: value.skuUnitId,
productNameSnapshot: product.name,
skuNameSnapshot: sku.name,
specSummarySnapshot: sku.specSummary,
unitSnapshot: unit?.name || "",
baseQuantityPerUnitSnapshot: unit?.baseQuantityPerUnit
skuUnitId: value.skuUnitId
});
}
function applyProductSelection(index: number, selection: ProductSelection): void {
skuCache.set(selection.sku.id || "", selection.sku);
productCache.set(selection.product.id || "", selection.product);
Object.assign(form.itemList[index], {
productNameSnapshot: selection.product.name,
productIdSnapshot: selection.product.id,
skuCodeSnapshot: selection.sku.code,
skuNameSnapshot: selection.sku.name,
specSummarySnapshot: selection.sku.specSummary,
unitSnapshot: selection.unit.name,
baseQuantityPerUnitSnapshot: selection.unit.baseQuantityPerUnit
});
}
@@ -396,13 +401,16 @@ async function loadData(): Promise<void> {
}
const result = await PackageAPI.configDetail(routeId.value);
const itemList = (result.itemList || []).map(createItem);
const skuIdList = [...new Set(itemList.map(item => item.skuId).filter((id): id is string => !!id))];
await Promise.all(skuIdList.map(async skuId => {
const sku = itemList.find(item => item.skuId === skuId)?.sku || await ProductAPI.skuDetail(skuId);
skuCache.set(skuId, sku);
if (sku.productId) {
productCache.set(sku.productId, await ProductAPI.detail(sku.productId));
for (const item of itemList) {
if (item.skuId && item.sku) {
skuCache.set(item.skuId, item.sku);
}
}
const productIdList = [...new Set(itemList
.map(item => item.sku?.productId || item.productIdSnapshot)
.filter((id): id is string => !!id))];
await Promise.all(productIdList.map(async productId => {
productCache.set(productId, await ProductAPI.detail(productId));
}));
Object.assign(form, {
id: result.id || routeId.value,
+108 -36
View File
@@ -199,11 +199,14 @@
<t-switch
v-model="fulfillAll"
size="small"
:disabled="!canFulfillImmediately"
:disabled="!canFulfillImmediately || isStockChecking"
/>
</t-form-item>
<t-form-item label-width="0">
<div class="red">全部履约会同步启用全额收款;请先准备全部产品库存,任意一步失败,整张订单都会回滚。</div>
<div class="stock-tips">
<div class="tips red">全部履约会同步启用全额收款;请先准备充足的库存,任意一步失败都会下单失败。</div>
<product-stock-preview v-if="stockPreviewList.length" :items="stockPreviewList" />
</div>
</t-form-item>
<t-form-item v-if="(paymentVerified && parsedPaymentAmount) || (receiveFullPayment && parsedSaleAmount)" label-width="0">
<photo-scroller
@@ -337,6 +340,7 @@ import CustomerList from "@/components/CustomerList.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import ProductStockPreview from "@/components/ProductStockPreview.vue";
import { usePackagePurchase } from "@/pages/package/usePackagePurchase";
import { useNavBarStore } from "@/store/navBarStore";
import { useSettingStore } from "@/store/settingStore";
@@ -351,10 +355,13 @@ import {
formatPackageAmount,
getPackageQuantityText,
normalizePackageQuantity,
parsePackageAmount
parsePackageAmount,
toPackageBaseQuantity
} from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { buildProductStockPreview, getProductStockShortageMessage, mergeProductStockRequirements } from "@/utils/productStock";
import type { ProductStockPreviewItem } from "@/utils/productStock";
import { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL, getProductSkuSnapshotText } from "@/utils/product";
@@ -510,6 +517,9 @@ const paymentPhotoUrlList = computed(() => paymentPhotoItems.value.map(item => i
const fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
const receiveFullPayment = ref(false);
const fulfillAll = ref(false);
const isStockChecking = ref(false);
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
let stockCheckToken = 0;
const parsedPaymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
const paymentVerified = computed(() => !receiveFullPayment.value
&& 0 < paymentAmountText.value.trim().length
@@ -553,11 +563,29 @@ const canReceiveImmediately = computed(() => userStore.hasPermissions(permission
&& userStore.hasPermissions(permissionCodes.fundReceive.verify));
const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
&& userStore.hasPermissions(permissionCodes.productStock.read)
&& (parsedSaleAmount.value === 0 || canReceiveImmediately.value));
watch(fulfillAll, value => {
if (value && parsedSaleAmount.value !== 0) {
receiveFullPayment.value = true;
watch(fulfillAll, async value => {
stockCheckToken += 1;
const token = stockCheckToken;
if (!value) {
isStockChecking.value = false;
return;
}
isStockChecking.value = true;
try {
if (!await validateDirectStock() && token === stockCheckToken && fulfillAll.value) {
fulfillAll.value = false;
return;
}
if (token === stockCheckToken && fulfillAll.value && parsedSaleAmount.value !== 0) {
receiveFullPayment.value = true;
}
} finally {
if (token === stockCheckToken) {
isStockChecking.value = false;
}
}
});
@@ -603,6 +631,14 @@ function getItemSku(item: PackagePurchaseItem): ProductSku | undefined {
return skuMap[item.skuId] || config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.sku;
}
function syncConfigSkuMap(value?: GaoPackageConfig): void {
for (const item of value?.itemList || []) {
if (item.skuId && item.sku) {
skuMap[item.skuId] = item.sku;
}
}
}
function getItemUnit(item: PackagePurchaseItem): ProductSkuUnit | undefined {
return getItemSku(item)?.unitList?.find(unit => unit.id === item.skuUnitId);
}
@@ -762,10 +798,70 @@ function validateItemDecimalQuantity(): boolean {
return true;
}
async function validateDirectStock(): Promise<boolean> {
stockPreviewList.value = [];
const selected = packagePurchaseItemList.value.filter(item => {
const quantity = normalizePackageQuantity(item.totalQuantity);
return !!quantity && quantity !== "0";
});
const skuIdList = [...new Set(selected.map(item => item.skuId).filter(Boolean))];
await Promise.all(skuIdList.map(async skuId => {
if (skuMap[skuId]) {
return;
}
try {
skuMap[skuId] = await ProductAPI.skuDetail(skuId);
} catch {
// 统一在下方提示,避免部分 SKU 查询失败时静默跳过库存校验
}
}));
for (const item of selected) {
if (!skuMap[item.skuId]) {
Message.warning(`${getItemName(item)} 的库存信息加载失败,请刷新后重试`);
return false;
}
if (!getItemUnit(item)) {
Message.warning(`${getItemName(item)} 的权益单位不存在,请刷新后重试`);
return false;
}
}
const requirementList = mergeProductStockRequirements(selected.flatMap(item => {
const sku = skuMap[item.skuId];
const unit = getItemUnit(item);
const quantity = normalizePackageQuantity(item.totalQuantity);
const baseQuantity = quantity && unit ? toPackageBaseQuantity(quantity, unit.baseQuantityPerUnit) : undefined;
if (!sku?.stockEnabled || !baseQuantity) {
return [];
}
return [{ skuId: item.skuId, baseQuantity, productName: getItemName(item), unitName: "基础单位", stockEnabled: sku.stockEnabled }];
}));
if (!requirementList.length) {
return true;
}
try {
const stockList = await ProductAPI.stockBatchDetail({ skuIdList: requirementList.map(item => item.skuId) });
stockPreviewList.value = buildProductStockPreview(stockList, requirementList);
const shortageMessage = getProductStockShortageMessage(stockList, requirementList);
if (shortageMessage) {
Message.warning(`${shortageMessage},不能直接履约`);
return false;
}
return true;
} catch (error) {
stockPreviewList.value = [];
Message.error(getErrorMessage(error, "库存查询失败,暂不能直接履约"));
return false;
}
}
async function submitRecord(): Promise<void> {
if (isSaving.value) {
return;
}
if (isStockChecking.value) {
Message.warning("库存正在预检,请稍候再提交");
return;
}
if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
return;
}
@@ -796,6 +892,7 @@ async function purchasePackage(): Promise<void> {
isSaving.value = true;
try {
if (fulfillAll.value && !await validateDirectStock()) return;
const result = await submitPurchase();
if (!result?.salesOrderId) {
Message.error("套餐购买成功但未返回完整订单关联,请联系管理员");
@@ -823,8 +920,8 @@ async function loadPageData(): Promise<void> {
return;
}
config.value = await PackageAPI.configDetail(configId.value);
syncConfigSkuMap(config.value);
applyConfig(config.value);
await loadConfigSkuList(config.value);
}
async function selectConfig(item: GaoPackageConfig): Promise<void> {
@@ -832,21 +929,11 @@ async function selectConfig(item: GaoPackageConfig): Promise<void> {
return;
}
config.value = await PackageAPI.configDetail(item.id);
await loadConfigSkuList(config.value);
syncConfigSkuMap(config.value);
applyConfig(config.value);
configSelectorVisible.value = false;
}
async function loadConfigSkuList(value?: GaoPackageConfig): Promise<void> {
const skuIdList = [...new Set((value?.itemList || []).map(item => item.skuId).filter(Boolean))];
await Promise.all(skuIdList.map(async skuId => {
try {
skuMap[skuId] = await ProductAPI.skuDetail(skuId);
} catch {
// 后端会校验最终权益单位,单个 SKU 的单位加载失败不影响套餐查看
}
}));
}
onMounted(async () => {
navBarStore.clearRight();
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
@@ -895,23 +982,8 @@ watch(config, value => {
width: 100%;
}
.straight-through {
width: 100%;
padding: .75rem;
background: var(--td-bg-color-secondarycontainer);
border-radius: .875rem;
.title {
font-size: .9375rem;
font-weight: 600;
}
.tips {
color: var(--td-text-color-secondary);
margin: .5rem .5rem 0;
font-size: .75rem;
line-height: 1.5;
}
.tips {
font-size: .875rem;
}
.customer-profile {
+8 -32
View File
@@ -142,7 +142,7 @@
<script setup lang="ts">
import type { DropdownValue } from "tdesign-mobile-vue";
import { PassTimeLabel, Time, Toolkit } from "timi-web";
import { PassTimeLabel, Toolkit } from "timi-web";
import { type PageResult, useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI";
import PointAPI from "@/api/PointAPI";
@@ -151,6 +151,7 @@ import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { Customer } from "@/types/Customer";
import type { PointLedger, PointLedgerChangeType, PointLedgerPage } from "@/types/Point";
import { getCustomerPhotoURL } from "@/utils/customer";
import { buildPresetDateRange, parseDateTime } from "@/utils/date";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
@@ -175,7 +176,7 @@ const customerKeyword = ref("");
const selectorVisible = ref(false);
const changeTypeFilter = ref("ALL");
const dateFilter = ref<DateFilter>("last30");
const dateRange = ref<[string, string]>(buildDateRange(dateFilter.value));
const dateRange = ref<[string, string]>(buildPresetDateRange("last30"));
const startDraft = ref("");
const endDraft = ref("");
const startDateVisible = ref(false);
@@ -253,7 +254,7 @@ async function selectDateFilter(value: DropdownValue): Promise<void> {
}
dateFilter.value = String(value) as DateFilter;
if (dateFilter.value !== "custom") {
dateRange.value = buildDateRange(dateFilter.value);
dateRange.value = buildPresetDateRange(dateFilter.value);
await reload();
}
}
@@ -436,39 +437,14 @@ async function toDetail(item: PointLedger): Promise<void> {
});
}
// ---------- 时间 ----------
function buildDateRange(type: DateFilter): [string, string] {
if (type === "all") {
return ["", ""];
}
const end = new Date();
end.setHours(23, 59, 0, 0);
if (type === "custom") {
return ["", Time.formatDateTime(end)];
}
const begin = new Date(end);
const dayCount = type === "today" ? 0 : type === "last7" ? 6 : 29;
begin.setDate(begin.getDate() - dayCount);
begin.setHours(0, 0, 0, 0);
return [Time.formatDateTime(begin), Time.formatDateTime(end)];
}
function buildUnixRange(): [number, number] {
if (!dateRange.value[0] && !dateRange.value[1]) {
return [0, Date.now()];
}
return [toTimestamp(dateRange.value[0], false), toTimestamp(dateRange.value[1], true)];
}
function toTimestamp(value: string, isEnd: boolean): number {
if (!value) {
return isEnd ? Date.now() : 0;
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0).getTime();
return [
parseDateTime(dateRange.value[0], false).getTime(),
parseDateTime(dateRange.value[1], true).getTime()
];
}
// ---------- 页面数据 ----------
+113 -8
View File
@@ -1,14 +1,40 @@
<template>
<div v-can="permissionCodes.pointRedeemSku.manage" class="redeem-sku-list">
<div class="filters">
<t-input
v-model.trim="keyword"
class="keyword"
type="search"
borderless
clearable
placeholder="搜索产品名称、编码或 SKU"
/>
</div>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="load">
<t-cell-group v-if="result.list.length" theme="card">
<t-cell
v-for="(item, index) in result.list"
:key="item.id"
class="item"
arrow
:description="item.unit?.name || '单位未知'"
v-entrance-transition="index * 30"
@click="toEdit(item)"
>
<template #left-icon>
<t-image
v-if="getProductPhoto(item)"
:src="getProductPhoto(item)"
:alt="item.product?.name || item.sku?.name || '产品图片'"
fit="cover"
class="avatar"
/>
<div
v-else
class="avatar fallback"
v-text="(item.product?.name || item.sku?.name || '产').slice(0, 1)"
/>
</template>
<template #title>
<div class="title">
<strong v-text="item.product?.name || item.sku?.name || '未命名产品'" />
@@ -19,10 +45,8 @@
/>
</div>
</template>
<template #description>
<span
v-text="`${item.sku?.code || item.sku?.specSummary || '默认 SKU'} · ${item.unit?.name || '单位未知'} · ${formatPoint(item.defaultPointCost)} / 单位`"
/>
<template #note>
<span class="orange" v-text="formatPoint(item.defaultPointCost)" />
</template>
</t-cell>
</t-cell-group>
@@ -37,22 +61,31 @@
</template>
<script setup lang="ts">
import { Toolkit } from "timi-web";
import PointAPI from "@/api/PointAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { PointRedeemSku, PointRedeemSkuStatus } from "@/types/Point";
import { Message } from "@/utils/message";
import { getProductPhotoURL } from "@/utils/product";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "PointRedeemSkuList" });
const router = useRouter();
const navBarStore = useNavBarStore();
const result = reactive({ total: 0, list: [] as PointRedeemSku[] });
const keyword = shallowRef("");
const index = ref(0);
const isLoading = ref(false);
const isRefreshing = ref(false);
const isNoMore = ref(false);
const pageSize = 20;
let reloadRequested = false;
async function reload(): Promise<void> {
if (isLoading.value) {
reloadRequested = true;
return;
}
index.value = 0;
result.total = 0;
result.list = [];
@@ -70,23 +103,40 @@ async function load(): Promise<void> {
}
isLoading.value = true;
try {
const page = await PointAPI.redeemSkuList({ index: index.value, size: 20 });
const page = await PointAPI.redeemSkuList({
index: index.value,
size: pageSize,
keyword: keyword.value.trim() || undefined
});
const list = page.list || [];
result.list = result.list.concat(list);
result.total = page.total || 0;
index.value++;
isNoMore.value = result.total <= result.list.length || list.length < 20;
isNoMore.value = result.total <= result.list.length || list.length < pageSize;
} catch {
Message.error("兑换配置加载失败");
} finally {
isLoading.value = false;
if (reloadRequested) {
reloadRequested = false;
await reload();
}
}
}
const reloadByKeyword = Toolkit.debounce(async () => {
await reload();
}, false);
function formatPoint(value?: number): string {
return `${(value || 0).toLocaleString("zh-CN")} 分`;
}
function getProductPhoto(item: PointRedeemSku): string {
const product = item.product || item.sku;
return product ? getProductPhotoURL(product) : "";
}
function statusText(status?: PointRedeemSkuStatus): string {
return ({
DRAFT: "草稿",
@@ -111,12 +161,54 @@ onMounted(async () => {
await reload();
});
onUnmounted(() => navBarStore.clearRight());
watch(keyword, () => {
reloadByKeyword();
});
onUnmounted(() => {
reloadByKeyword.cancel();
navBarStore.clearRight();
});
</script>
<style scoped lang="less">
.redeem-sku-list {
padding: calc(var(--app-nav-height) + 1rem) 0 5rem;
gap: 1rem;
display: flex;
padding: var(--app-nav-height) 0 5rem;
flex-direction: column;
.filters {
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;
.keyword {
--td-input-vertical-padding: .25rem 1rem;
border: 1px solid var(--td-component-border);
background: var(--td-bg-color-container);
border-radius: 1rem;
}
}
.item {
padding: 10px;
border-radius: 1rem;
.avatar {
width: 3.5rem;
height: 3.5rem;
border-radius: .875rem;
}
}
.title {
gap: .5rem;
@@ -131,4 +223,17 @@ onUnmounted(() => navBarStore.clearRight());
text-align: center;
}
}
:global(html.theme-dark) {
.redeem-sku-list {
.filters {
background: #10161f;
}
.fallback {
color: #ccfbf1;
background: #115e59;
}
}
}
</style>
+10 -5
View File
@@ -6,7 +6,7 @@
:customer="customer"
v-entrance-transition
/>
<t-cell-group v-if="item" title="兑换信息" theme="card">
<t-cell-group v-if="item" title="兑换信息" theme="card" v-entrance-transition="70">
<t-cell title="客户" :note="item.customer?.name || item.customerId || '-'" />
<t-cell title="兑换商品" :note="item.productNameSnapshot || item.skuNameSnapshot || '-'" />
<t-cell title="兑换数量" :note="`${quantityText(item.quantity)} ${item.unitSnapshot || ''}`" />
@@ -24,7 +24,7 @@
/>
</div>
</t-cell-group>
<t-cell-group v-if="returnList.length" title="退货记录" theme="card">
<t-cell-group v-if="returnList.length" title="退货记录" theme="card" v-entrance-transition="140">
<t-cell
v-for="record in returnList"
:key="record.id"
@@ -33,9 +33,14 @@
/>
</t-cell-group>
<t-empty v-if="!item" :description="loading ? '正在加载兑换单' : '兑换单不存在'" />
<t-button v-if="canReturn" class="return-button cell-button" theme="danger" @click="returnVisible = true">
冲正兑换
</t-button>
<t-button
v-if="canReturn"
class="return-button cell-button"
theme="danger"
content="冲正兑换"
v-entrance-transition="140"
@click="returnVisible = true"
/>
</div>
<bottom-popup
v-model="returnVisible"
+36 -101
View File
@@ -47,19 +47,22 @@
@scrolltolower="load"
>
<div class="list-wrap">
<t-cell-group v-if="isCustomDate" class="date-summary" theme="card">
<t-cell
title="开始时间"
:note="dateRange[0] || '-'"
arrow
@click="openStartTime"
/>
<t-cell
title="结束时间"
:note="dateRange[1] || '-'"
arrow
@click="openEndTime"
/>
<t-cell-group class="date-summary" theme="card" v-entrance-transition>
<template v-if="isCustomDate">
<t-cell
title="开始时间"
:note="dateRange[0] || '-'"
arrow
@click="openStartTime"
/>
<t-cell
title="结束时间"
:note="dateRange[1] || '-'"
arrow
@click="openEndTime"
/>
</template>
<t-cell title="总数据量" :note="Text.display(result.total)" />
</t-cell-group>
<div v-if="result.list.length" class="items">
<t-cell-group theme="card">
@@ -68,7 +71,7 @@
:key="item.id"
class="item"
arrow
v-entrance-transition="index * 30"
v-entrance-transition="70 + index * 30"
@click="toDetail(item)"
>
<template #left-icon>
@@ -95,8 +98,11 @@
/>
</div>
</template>
<template #note>
<span class="orange" v-text="formatPoint(item.actualPointCost)" />
</template>
<template #description>
<span v-text="`${item.customer?.name || item.customerId || '未知客户'} · ${quantityText(item.quantity)} ${item.unitSnapshot || ''} · 实扣 ${formatPoint(item.actualPointCost)}`" />
<span v-text="`${item.customer?.name || item.customerId || '未知客户'} · ${quantityText(item.quantity)} ${item.unitSnapshot || ''}`" />
</template>
</t-cell>
</t-cell-group>
@@ -105,7 +111,6 @@
v-else
:description="loading ? '正在加载兑换记录' : hasFilter ? '未找到匹配的兑换记录' : '暂无兑换记录'"
/>
<div class="tips" v-text="`共 ${result.total} 条兑换记录`" />
</div>
</t-pull-down-refresh>
</div>
@@ -146,13 +151,14 @@
<script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { Text, Time, Toolkit } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import PointAPI from "@/api/PointAPI";
import CustomerSelector from "@/components/CustomerSelector.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer";
import type { PointRedemption, PointRedemptionStatus } from "@/types/Point";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
import { Message } from "@/utils/message";
import { getPackageQuantityText } from "@/utils/package";
import { getCustomerPhotoURL } from "@/utils/customer";
@@ -178,7 +184,7 @@ const keyword = ref(getQueryText(route.query.keyword));
const customerId = ref(getQueryText(route.query.customerId));
const selectedCustomer = ref<Customer>();
const customerSelectorVisible = ref(false);
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
const dateFilterType = ref<DateFilterType>(parseDateFilterType(getQueryText(route.query.dateType), dateOptions, "today"));
const dateRange = ref<[string, string]>(parseDateRange(dateFilterType.value));
const startDraft = ref("");
const endDraft = ref("");
@@ -188,7 +194,6 @@ const index = ref(0);
const loading = ref(false);
const refreshing = ref(false);
const noMore = ref(false);
let keywordTimer: number | undefined;
let reloadRequested = false;
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "时间范围");
const isCustomDate = computed(() => dateFilterType.value === "custom");
@@ -198,14 +203,9 @@ function getQueryText(value: unknown): string {
return Array.isArray(value) ? "" : typeof value === "string" ? value : "";
}
function parseDateFilterType(value: unknown): DateFilterType {
const dateFilter = getQueryText(value);
return dateOptions.some(item => item.value === dateFilter) ? dateFilter as DateFilterType : "today";
}
function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") {
return buildPresetRange(type);
return buildPresetDateRange(type);
}
return [getQueryText(route.query.beginAt), getQueryText(route.query.endAt)];
}
@@ -292,7 +292,7 @@ async function selectDateFilter(value: DropdownValue): Promise<void> {
}
dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
dateRange.value = buildPresetDateRange(dateFilterType.value);
await replaceFilterQuery();
await reload();
} else {
@@ -354,45 +354,6 @@ async function replaceFilterQuery(): Promise<void> {
});
}
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = new Date();
current.setHours(0, 0, 0, 0);
const day = current.getDay() || 7;
const thisWeekMonday = new Date(current);
thisWeekMonday.setDate(current.getDate() - day + 1);
const lastWeekMonday = new Date(thisWeekMonday);
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
const lastWeekSunday = new Date(thisWeekMonday);
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
}
if (type === "thisMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
}
if (type === "lastMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
const end = new Date(current.getFullYear(), current.getMonth(), 0);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
today: 0,
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
const value = `${Time.formatDate(date)} 00:00`;
return [value, `${Time.formatDate(date)} 23:59`];
}
function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") {
return [0, Time.toTimestamp(new Date())];
@@ -403,16 +364,6 @@ function buildUnixRange(): [number | undefined, number | undefined] {
];
}
function parseDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
function statusText(value?: PointRedemptionStatus): string {
return ({
COMPLETED: "已兑换",
@@ -443,15 +394,16 @@ onMounted(async () => {
}
});
const reloadByKeyword = Toolkit.debounce(async () => {
await reload();
}, false);
watch(keyword, () => {
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
await reload();
}, 300);
reloadByKeyword();
});
onUnmounted(() => {
window.clearTimeout(keywordTimer);
reloadByKeyword.cancel();
navBarStore.clearRight();
});
</script>
@@ -506,22 +458,11 @@ onUnmounted(() => {
}
.list-wrap {
gap: 1rem;
display: flex;
min-height: 70vh;
padding-top: 1rem;
.date-summary {
:deep(.t-cell__title) {
flex: 0 0 4.5rem;
}
:deep(.t-cell__note) {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
}
flex-direction: column;
.items {
gap: 1rem;
@@ -546,12 +487,6 @@ onUnmounted(() => {
}
}
}
.tips {
color: var(--td-text-color-secondary);
padding: 1rem;
text-align: center;
}
}
}
+57 -12
View File
@@ -203,7 +203,7 @@
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import type { ColorObject } from "tdesign-mobile-vue";
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";
@@ -212,7 +212,7 @@ import ProductSkuUnitEditor from "@/pages/product/component/ProductSkuUnitEditor
import ProductSkuUnitBatchEditor from "@/pages/product/component/ProductSkuUnitBatchEditor.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { PhotoItem } from "@/types/Photo";
import type { Product, ProductAttachmentType, ProductCategory, ProductSku, ProductSkuUnit, ProductSpec } from "@/types/Product";
import type { Product, ProductAttachmentType, ProductCategory, ProductSku, ProductSkuUnit, ProductSpec, ProductType } from "@/types/Product";
import type { ProductAttachmentItem, ProductForm, ProductSkuForm as SkuForm, ProductSkuUnitForm as UnitForm, ProductSpecForm as SpecForm, ProductSpecValueForm as SpecValueForm } from "@/types/ProductEditor";
import { compressUploadImage } from "@/utils/imageCompression";
import { getAttachmentReadURL } from "@/utils/attachment";
@@ -464,19 +464,64 @@ function openTypePicker(): void {
typePickerVisible.value = true;
}
function confirmType(value: Array<string | number>): void {
async function confirmType(value: Array<string | number>): Promise<void> {
const type = String(value[0] || "");
if (type === "SERVICE" || type === "GOODS" || type === "CONSUMABLE") {
form.productType = type;
if (type === "SERVICE") {
form.stockEnabled = false;
form.skuList.forEach(sku => {
sku.stockEnabled = false;
sku.unitList.forEach(unit => { unit.allowPurchase = false; });
});
}
if (type !== "SERVICE" && type !== "GOODS" && type !== "CONSUMABLE") {
typePickerVisible.value = false;
return;
}
typePickerVisible.value = false;
if (type !== "SERVICE" || form.productType === "SERVICE") {
applyProductType(type);
return;
}
if (!await canChangeToService()) {
return;
}
const dialog = DialogPlugin.confirm({
title: "切换为服务产品",
content: "切换后将关闭产品及全部 SKU 的库存管理,并清空最低库存设置;已有库存流水会保留。确定继续吗?",
cancelBtn: "取消",
confirmBtn: "确定切换",
onCancel() {
dialog.destroy();
},
onConfirm() {
dialog.destroy();
applyProductType("SERVICE");
}
});
}
function applyProductType(type: ProductType): void {
form.productType = type;
if (type !== "SERVICE") {
return;
}
form.stockEnabled = false;
form.skuList.forEach(sku => {
sku.stockEnabled = false;
sku.minStockQuantityText = "";
sku.unitList.forEach(unit => { unit.allowPurchase = false; });
});
}
async function canChangeToService(): Promise<boolean> {
const skuIdList = form.skuList.map(sku => sku.id).filter((id): id is string => !!id);
if (!skuIdList.length) {
return true;
}
try {
const stockList = await ProductAPI.stockBatchDetail({ skuIdList });
if (stockList.some(stock => 0 < Number(stock.reservedQuantity || 0))) {
Message.warning("当前产品存在预占库存,不能切换为服务产品,请先完成或取消相关订单");
return false;
}
return true;
} catch (error) {
Message.error(getErrorMessage(error, "无法确认预占库存,暂不能切换为服务产品"));
return false;
}
}
function openCategoryPicker(): void {
+69 -8
View File
@@ -9,6 +9,13 @@
</template>
</product-profile>
<t-cell-group theme="card" title="库存" v-entrance-transition="70">
<t-cell
v-if="1 < unitList.length"
title="显示单位"
:note="selectedUnitName"
arrow
@click="openUnitPicker"
/>
<t-cell class="empty-cell-title-margin">
<template #title>
<span></span>
@@ -18,20 +25,20 @@
<div class="main">
<span class="label">库存</span>
<div class="value">
<strong class="text" v-text="getProductQuantityText(stock.onHandQuantity)" />
<span class="unit" v-text="getProductBaseUnitName(sku) || '基础单位'" />
<strong class="text" v-text="displayStock.onHandQuantity" />
<span class="unit" v-text="selectedUnitName" />
</div>
</div>
<div class="footer">
<div class="available">
<span class="label">可用</span>
<strong class="value" v-text="getProductQuantityText(stock.availableQuantity)" />
<span class="unit" v-text="getProductBaseUnitName(sku) || '基础单位'" />
<strong class="value" v-text="displayStock.availableQuantity" />
<span class="unit" v-text="selectedUnitName" />
</div>
<div class="reserved">
<span class="label">预占</span>
<strong class="value" v-text="getProductQuantityText(stock.reservedQuantity)" />
<span class="unit" v-text="getProductBaseUnitName(sku) || '基础单位'" />
<strong class="value" v-text="displayStock.reservedQuantity" />
<span class="unit" v-text="selectedUnitName" />
</div>
</div>
</div>
@@ -74,22 +81,33 @@
:operation="operationType"
@success="loadAll"
/>
<t-popup v-model="unitPickerVisible" placement="bottom">
<t-picker
v-model="unitPickerDraft"
:columns="unitPickerOptions"
title="选择库存展示单位"
@cancel="unitPickerVisible = false"
@confirm="confirmUnit"
/>
</t-popup>
</div>
</template>
<script setup lang="ts">
import { shallowRef } from "vue";
import { useUserStore } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI";
import ProductProfile from "@/pages/product/component/ProductProfile.vue";
import ProductStockOperationPopup from "@/pages/product/ProductStockOperationPopup.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { Product, ProductSku, ProductStock, StockOperationType } from "@/types/Product";
import type { Product, ProductSku, ProductStock, ProductSkuUnit, StockOperationType } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { getProductBaseUnitName, getProductQuantityText } from "@/utils/product";
import { convertBaseQuantityToPackageQuantity } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
type ManualStockOperation = Extract<StockOperationType, "MANUAL_IN" | "MANUAL_OUT" | "STOCKTAKE_GAIN" | "STOCKTAKE_LOSS">;
type PickerValue = string | number;
defineOptions({ name: "ProductStockDetail" });
@@ -103,7 +121,21 @@ const stock = ref<ProductStock>();
const isLoading = ref(false);
const operationVisible = ref(false);
const operationType = ref<ManualStockOperation>("MANUAL_IN");
const selectedUnitId = shallowRef("");
const unitPickerVisible = shallowRef(false);
const unitPickerDraft = ref<PickerValue[]>([]);
const canOperate = computed(() => userStore.hasPermissions(permissionCodes.productStock.create));
const unitList = computed(() => (sku.value?.unitList || []).filter(unit => !!unit.id));
const selectedUnit = computed<ProductSkuUnit | undefined>(() => unitList.value.find(unit => unit.id === selectedUnitId.value)
|| unitList.value.find(unit => unit.isBase)
|| unitList.value[0]);
const selectedUnitName = computed(() => selectedUnit.value?.name || "基础单位");
const unitPickerOptions = computed(() => [unitList.value.map(unit => ({ label: unit.name || "未命名单位", value: unit.id || "" }))]);
const displayStock = computed(() => ({
onHandQuantity: getStockQuantityText(stock.value?.onHandQuantity),
availableQuantity: getStockQuantityText(stock.value?.availableQuantity),
reservedQuantity: getStockQuantityText(stock.value?.reservedQuantity)
}));
const operationList: Array<{ text: string; icon: string; type: ManualStockOperation; theme: "primary" | "danger" }> = [
{ text: "手工入库", icon: "import", type: "MANUAL_IN", theme: "primary" },
{ text: "手工出库", icon: "export", type: "MANUAL_OUT", theme: "danger" },
@@ -129,6 +161,34 @@ function getStockTheme(item: ProductStock): "danger" | "success" | "default" {
return 0 < min && available < min ? "danger" : "success";
}
function getStockQuantityText(value?: number | string): string {
return convertBaseQuantityToPackageQuantity(value ?? "0", selectedUnit.value?.baseQuantityPerUnit ?? "1") || "0";
}
function setDefaultUnit(nextSku: ProductSku): void {
const nextUnitList = nextSku.unitList || [];
if (selectedUnitId.value && nextUnitList.some(unit => unit.id === selectedUnitId.value)) {
return;
}
selectedUnitId.value = nextUnitList.find(unit => unit.isBase)?.id || nextUnitList[0]?.id || "";
}
function openUnitPicker(): void {
if (1 >= unitList.value.length) {
return;
}
unitPickerDraft.value = [selectedUnit.value?.id || ""];
unitPickerVisible.value = true;
}
function confirmUnit(value: PickerValue[]): void {
const unitId = String(value[0] || "");
if (unitList.value.some(unit => unit.id === unitId)) {
selectedUnitId.value = unitId;
}
unitPickerVisible.value = false;
}
async function loadAll(): Promise<void> {
const skuId = String(route.params.skuId || "");
if (!skuId) {
@@ -143,6 +203,7 @@ async function loadAll(): Promise<void> {
]);
product.value = productResult;
sku.value = skuResult;
setDefaultUnit(skuResult);
stock.value = { ...stockResult, sku: skuResult };
} finally {
isLoading.value = false;
+9 -2
View File
@@ -29,7 +29,14 @@
@click="toSkuList(item)"
>
<template #leftIcon>
<div class="avatar fallback" v-text="(item.productName || '产').slice(0, 1)" />
<t-image
v-if="getProductPhotoURL(item)"
:src="getProductPhotoURL(item)"
:alt="item.productName || '产品图片'"
fit="cover"
class="avatar"
/>
<div v-else class="avatar fallback" v-text="(item.productName || '产').slice(0, 1)" />
</template>
<template #title>
<div class="title">
@@ -67,7 +74,7 @@ import ProductAPI from "@/api/ProductAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { ProductStockProduct } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { getProductQuantityText } from "@/utils/product";
import { getProductPhotoURL, getProductQuantityText } from "@/utils/product";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
+102 -22
View File
@@ -20,43 +20,63 @@
@refresh="refresh"
@scrolltolower="loadMore"
>
<t-cell-group v-if="recordList.length" class="list" theme="card">
<t-cell-group v-if="recordList.length" class="list empty-cell-title-margin" theme="card">
<t-cell
v-for="(record, index) in recordList"
:key="record.id || `${record.occurredAt}-${record.requestNo}-${index}`"
class="record"
class="item"
:arrow="!!record.id"
:hover="!!record.id"
v-entrance-transition="index * 30"
@click="toDetail(record)"
>
<template #leftIcon>
<span class="icon" :class="getDeltaClass(record.onHandDelta)">
<t-image
v-if="getCustomerPhotoURL(record.customer, 64)"
:src="getCustomerPhotoURL(record.customer, 64)"
:alt="getCustomerText(record)"
fit="cover"
class="avatar"
/>
<div
v-else-if="record.customer"
class="avatar fallback"
v-text="getCustomerText(record).slice(0, 1)"
/>
<t-image
v-else-if="record.operator"
:src="getOperatorAvatar(record)"
:alt="getOperatorText(record)"
fit="cover"
class="avatar"
/>
<span v-else class="icon" :class="getDeltaClass(record.onHandDelta)">
<t-icon :name="getOperationIcon(record.operationType)" size="1.25rem" />
</span>
</template>
<template #title>
<div class="title">
<strong v-text="getStockOperationText(record.operationType)" />
<span v-if="record.customer" class="actor" v-text="getCustomerText(record)" />
<span v-else-if="record.operator" class="actor" v-text="getOperatorText(record)" />
<strong class="operation" v-text="getStockOperationText(record.operationType)" />
<strong class="product-name" v-text="record.productNameSnapshot || '未命名产品'" />
<span
v-if="record.quantity !== undefined && record.quantity !== null && record.quantity !== ''"
class="quantity"
v-text="getStockQuantityText(record)"
/>
</div>
</template>
<template #note>
<span
class="delta"
:class="getDeltaClass(record.onHandDelta)"
v-text="getDeltaText(record.onHandDelta)"
/>
</template>
<template #description>
<div class="description">
<strong v-text="record.productNameSnapshot || '未命名产品'" />
<div>
<pass-time-label :timestamp="record.occurredAt" underline />
<pass-time-label :timestamp="record.occurredAt" underline />
<div class="delta">
<span>库存变更:</span>
<span
class="value"
:class="getDeltaClass(record.onHandDelta)"
v-text="getDeltaText(record.onHandDelta)"
/>
</div>
</div>
</template>
@@ -71,6 +91,7 @@
<script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue";
import { PassTimeLabel } from "timi-web";
import { UserAPI } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { StockOperationType, StockRecord, StockRecordDirection } from "@/types/Product";
@@ -78,6 +99,8 @@ import { getErrorMessage } from "@/utils/error";
import { getProductQuantityText, getStockOperationText } from "@/utils/product";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { getCustomerPhotoURL } from "@/utils/customer";
import { buildFallbackAvatar } from "@/utils/avatar";
defineOptions({ name: "ProductStockRecordList" });
@@ -159,6 +182,18 @@ function getStockQuantityText(record: StockRecord): string {
return `${getProductQuantityText(record.quantity)}${unitText}`;
}
function getCustomerText(record: StockRecord): string {
return record.customer?.name || record.customer?.code || "未知客户";
}
function getOperatorText(record: StockRecord): string {
return record.operator?.nick || record.operator?.name || "未知操作人";
}
function getOperatorAvatar(record: StockRecord): string {
return UserAPI.getAvatarURL(record.operator) || buildFallbackAvatar(getOperatorText(record), "#0f766e");
}
function getDeltaText(value?: number | string): string {
const amount = Number(value || 0);
const text = String(value || "0").replace(/^-/, "");
@@ -340,7 +375,24 @@ onUnmounted(() => {
.list {
margin: 0;
.record {
.item {
padding: 10px;
min-width: 0;
:deep(.t-cell__title) {
min-width: 0;
width: 0;
}
:deep(.t-cell__title-text) {
min-width: 0;
width: 100%;
}
.avatar {
width: 3.5rem;
height: 3.5rem;
}
.icon {
width: 2.25rem;
@@ -354,23 +406,51 @@ onUnmounted(() => {
.title {
gap: .5rem;
min-width: 0;
max-width: 100%;
flex: 1;
display: flex;
font-size: .75rem;
align-items: center;
white-space: nowrap;
.actor {
color: var(--td-text-color-secondary);
flex: 0 0 auto;
}
.operation {
flex: 0 0 auto;
}
.product-name {
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quantity {
color: var(--td-text-color-secondary);
font-size: .875rem;
flex: 0 0 auto;
font-weight: 400;
}
}
.delta {
font-size: 1.25rem;
}
.description {
gap: .125rem;
display: grid;
gap: .5rem;
display: flex;
font-size: .75rem;
align-items: center;
justify-content: space-between;
.delta {
.value {
font-size: 1.25rem;
}
}
}
}
}
+14 -2
View File
@@ -32,7 +32,14 @@
@click="toDetail(item)"
>
<template #leftIcon>
<div class="avatar fallback" v-text="(item.sku?.name || 'S').slice(0, 1)" />
<t-image
v-if="getSkuPhotoURL(item)"
:src="getSkuPhotoURL(item)"
:alt="item.sku?.name || 'SKU 图片'"
fit="cover"
class="avatar"
/>
<div v-else class="avatar fallback" v-text="(item.sku?.name || 'S').slice(0, 1)" />
</template>
<template #title>
<div class="title">
@@ -71,7 +78,7 @@ import ProductAPI from "@/api/ProductAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { Product, ProductStock } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { getProductQuantityText } from "@/utils/product";
import { getProductPhotoURL, getProductQuantityText } from "@/utils/product";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
@@ -102,6 +109,11 @@ const emptyText = computed(() => {
}
return "该产品暂无启用库存管理的 SKU";
});
function getSkuPhotoURL(item: ProductStock): string {
return item.sku ? getProductPhotoURL(item.sku) : "";
}
let keywordTimer: number | undefined;
let requestToken = 0;
let suppressKeywordFetch = false;
+5 -52
View File
@@ -187,6 +187,7 @@ import type {
ConsumptionRecordSourceType,
ConsumptionRecordStatus
} from "@/types/ConsumptionRecord";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
import { getErrorMessage } from "@/utils/error";
import { getCustomerPhotoURL } from "@/utils/customer";
import { Message } from "@/utils/message";
@@ -217,7 +218,7 @@ const customerId = ref("");
const selectedCustomer = ref<Customer>();
const customerSelectorVisible = ref(false);
const dateFilterType = ref<DateFilterType>("all");
const dateRange = ref<[string, string]>(buildPresetRange("all"));
const dateRange = ref<[string, string]>(buildPresetDateRange("all"));
const startDraft = ref("");
const endDraft = ref("");
const startVisible = ref(false);
@@ -247,7 +248,7 @@ function applyRouteQuery(): void {
customerId.value = getText(route.query.customerId);
sourceType.value = ["ALL", "LEGACY_EVENT", "SALES_ORDER"].includes(routeSourceType) ? routeSourceType as ConsumptionRecordSourceType | "ALL" : "ALL";
status.value = ["ALL", "ACTIVE", "REVOKED"].includes(routeStatus) ? routeStatus as ConsumptionRecordStatus | "ALL" : "ALL";
dateFilterType.value = dateOptions.some(item => item.value === routeDateType) ? routeDateType as DateFilterType : "all";
dateFilterType.value = parseDateFilterType(routeDateType, dateOptions, "all");
dateRange.value = parseDateRange(dateFilterType.value, getText);
}
@@ -270,7 +271,7 @@ async function selectDateFilter(value: DropdownValue): Promise<void> {
dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
dateRange.value = buildPresetDateRange(dateFilterType.value);
await reloadRecords();
}
}
@@ -368,49 +369,11 @@ function buildPage(): ConsumptionRecordPage {
function parseDateRange(type: DateFilterType, getText: (value: unknown) => string): [string, string] {
if (type !== "custom") {
return buildPresetRange(type);
return buildPresetDateRange(type);
}
return [getText(route.query.beginAt), getText(route.query.endAt)];
}
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = new Date();
current.setHours(0, 0, 0, 0);
const day = current.getDay() || 7;
const thisWeekMonday = new Date(current);
thisWeekMonday.setDate(current.getDate() - day + 1);
const lastWeekMonday = new Date(thisWeekMonday);
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
const lastWeekSunday = new Date(thisWeekMonday);
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
}
if (type === "thisMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
}
if (type === "lastMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
const end = new Date(current.getFullYear(), current.getMonth(), 0);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
today: 0,
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
return [`${Time.formatDate(date)} 00:00`, `${Time.formatDate(date)} 23:59`];
}
function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") {
return [undefined, undefined];
@@ -421,16 +384,6 @@ function buildUnixRange(): [number | undefined, number | undefined] {
];
}
function parseDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
async function reloadRecords(value?: DropdownValue): Promise<void> {
if (value && Array.isArray(value)) {
return;
+122 -68
View File
@@ -18,7 +18,7 @@
</t-dropdown-menu>
</div>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
<t-cell-group v-if="recordList.length" theme="card">
<t-cell-group v-if="recordList.length" class="empty-cell-title-margin" theme="card">
<t-cell
class="item"
v-for="(item, index) in recordList"
@@ -27,47 +27,59 @@
v-entrance-transition="index * 30"
@click="toDetail(item)"
>
<template #leftIcon>
<t-image
v-if="getCustomerPhotoURL(item.customer, 64)"
:src="getCustomerPhotoURL(item.customer, 64)"
:alt="getCustomerText(item)"
fit="cover"
class="avatar"
/>
<div v-else class="avatar fallback" v-text="getCustomerText(item).slice(0, 1)" />
</template>
<template #title>
<div class="title">
<span v-text="getFundOperationText(item.operationType)" />
<t-tag
:content="getRecordStatusText(item)"
variant="light-outline"
:theme="getRecordStatusTheme(item)"
/>
<span class="customer" v-text="getCustomerText(item)" />
<div class="operation">
<span v-text="getFundOperationText(item.operationType)" />
<t-tag
size="small"
:content="getRecordStatusText(item)"
variant="light-outline"
:theme="getRecordStatusTheme(item)"
/>
</div>
</div>
</template>
<template #description>
<div class="description">
<div class="field">
<span class="label">发起人</span>
<span class="text-bold" v-text="getOperatorText(item)" />
</div>
<div v-if="item.verifier" class="field">
<span class="label">核销人</span>
<span class="text-bold" v-text="getVerifierText(item)" />
</div>
<div>
<div class="operator">
<div class="field">
<span class="label">发起人</span>
<span class="text-bold" v-text="getOperatorText(item)" />
</div>
<div v-if="item.verifier" class="field">
<span class="label">核销人</span>
<span class="text-bold" v-text="getVerifierText(item)" />
</div>
<pass-time-label :timestamp="item.occurredAt" underline />
</div>
</div>
</template>
<template #note>
<div class="amounts">
<div class="order-amount">
<span class="label">订单总价</span>
<span>¥</span>
<span v-text="formatPackageAmount(item.salesOrderAmount)" />
</div>
<div class="unpaid-amount">
<span class="label">剩余待收</span>
<span>¥</span>
<span v-text="formatPackageAmount(item.salesOrderUnpaidAmountSnapshot)" />
</div>
<div class="record-amount">
<span class="label">流水金额</span>
<span class="red">¥</span>
<span class="value red" v-text="formatPackageAmount(item.amount)" />
<div class="amounts">
<div class="order-amount">
<span class="label">订单总价</span>
<span>¥</span>
<span v-text="formatPackageAmount(item.salesOrderAmount)" />
</div>
<div class="unpaid-amount">
<span class="label">剩余待收</span>
<span>¥</span>
<span v-text="formatPackageAmount(item.salesOrderUnpaidAmountSnapshot)" />
</div>
<div class="record-amount">
<span class="label">流水金额</span>
<span class="red">¥</span>
<span class="value red" v-text="formatPackageAmount(item.amount)" />
</div>
</div>
</div>
</template>
@@ -90,6 +102,7 @@ import { formatPackageAmount } from "@/utils/package";
import { getFundOperationText, getFundStatusText } from "@/utils/salesOrder";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { getCustomerPhotoURL } from "@/utils/customer";
import { PassTimeLabel } from "timi-web";
defineOptions({ name: "FundRecordList" });
@@ -184,6 +197,10 @@ function getVerifierText(item: FundRecord): string {
return item.verifier?.name || item.verifier?.nick || item.verifierUserId || "未核销";
}
function getCustomerText(item: FundRecord): string {
return item.customer?.name || item.customer?.code || item.customerId || "未知客户";
}
function getStatusTheme(statusValue?: FundRecordStatus): "success" | "warning" | "danger" | "default" {
return statusValue === "VERIFIED" ? "success" : statusValue === "PENDING" ? "warning" : statusValue === "REJECTED" ? "danger" : "default";
}
@@ -276,52 +293,76 @@ onUnmounted(() => navBarStore.clearRight());
.item {
.avatar {
width: 3.5rem;
height: 3.5rem;
}
.title {
gap: .5rem;
display: flex;
font-size: .75rem;
align-items: center;
justify-content: space-between;
.customer {
min-width: 0;
overflow: hidden;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.operation {
gap: .5rem;
display: flex;
flex-shrink: 0;
align-items: center;
}
}
.description {
gap: 0;
color: var(--td-text-color-secondary);
display: flex;
font-size: .75rem;
flex-wrap: wrap;
line-height: 1.5;
flex-direction: column;
align-items: start;
justify-content: space-between;
.field {
gap: .25rem;
display: inline-flex;
}
.label {
color: var(--td-text-color-placeholder);
}
}
.amounts {
display: flex;
font-size: .75rem;
align-items: end;
flex-direction: column;
.label {
color: var(--td-text-color-placeholder);
}
.order-amount,
.unpaid-amount,
.record-amount {
gap: .125rem;
.operator {
gap: 0;
color: var(--td-text-color-secondary);
display: flex;
align-items: baseline;
white-space: nowrap;
font-size: .75rem;
flex-wrap: wrap;
line-height: 1.5;
flex-direction: column;
.field {
gap: .25rem;
display: inline-flex;
}
.label {
color: var(--td-text-color-placeholder);
}
}
.record-amount .value {
font-size: 1.25rem;
.amounts {
display: flex;
font-size: .75rem;
line-height: 1.5;
align-items: end;
flex-direction: column;
.label {
color: var(--td-text-color-placeholder);
}
.order-amount,
.unpaid-amount,
.record-amount {
display: flex;
align-items: baseline;
white-space: nowrap;
}
}
}
}
@@ -332,4 +373,17 @@ onUnmounted(() => navBarStore.clearRight());
text-align: center;
}
}
:deep(.sales-order-record-list .avatar .t-image__img) {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
:global(html.theme-dark) .sales-order-record-list {
.fallback {
background: #115e59;
}
}
</style>
+77 -13
View File
@@ -165,11 +165,14 @@
<t-switch
v-model="fulfillAll"
size="small"
:disabled="!canFulfillImmediately"
:disabled="!canFulfillImmediately || isStockChecking"
/>
</t-form-item>
<t-form-item label-width="0">
<div class="red">全部履约会同步启用全额收款;请先准备全部产品库存,任意一步失败,整张订单都会回滚。</div>
<div class="stock-tips">
<div class="tips red">全部履约会同步启用全额收款;请先准备充足的库存,任意一步失败都会下单失败。</div>
<product-stock-preview v-if="stockPreviewList.length" :items="stockPreviewList" />
</div>
</t-form-item>
<t-form-item v-if="(paymentVerified && paymentAmount) || (receiveFullPayment && totalAmount)" label-width="0">
<photo-scroller
@@ -230,15 +233,18 @@ import CodeScanner from "@/components/CodeScanner.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import ProductStockPreview from "@/components/ProductStockPreview.vue";
import type { Customer } from "@/types/Customer";
import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
import type { PhotoItem } from "@/types/Photo";
import type { DirectProductOrderItemRequest } from "@/types/SalesOrder";
import { createPackageRequestNo, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { createPackageRequestNo, normalizePackageQuantity, parsePackageAmount, toPackageBaseQuantity } from "@/utils/package";
import { formatProductAmount, getProductQuantityStep } from "@/utils/product";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { buildProductStockPreview, getProductStockShortageMessage, mergeProductStockRequirements } from "@/utils/productStock";
import type { ProductStockPreviewItem } from "@/utils/productStock";
import { uploadPhotoList } from "@/utils/photo";
import { useNavBarStore } from "@/store/navBarStore";
import { useSettingStore } from "@/store/settingStore";
@@ -268,6 +274,9 @@ const paymentPhotoUrlList = computed(() => paymentPhotoItems.value.map(item => i
const fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
const receiveFullPayment = ref(false);
const fulfillAll = ref(false);
const isStockChecking = ref(false);
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
let stockCheckToken = 0;
const paymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
const paymentVerified = computed(() => !receiveFullPayment.value
&& 0 < paymentAmountText.value.trim().length
@@ -289,11 +298,29 @@ const canReceiveImmediately = computed(() => userStore.hasPermissions(permission
&& userStore.hasPermissions(permissionCodes.fundReceive.verify));
const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
&& userStore.hasPermissions(permissionCodes.productStock.read)
&& (totalAmount.value === 0 || canReceiveImmediately.value));
watch(fulfillAll, value => {
if (value && totalAmount.value !== 0) {
receiveFullPayment.value = true;
watch(fulfillAll, async value => {
stockCheckToken += 1;
const token = stockCheckToken;
if (!value) {
isStockChecking.value = false;
return;
}
isStockChecking.value = true;
try {
if (!await validateDirectStock() && token === stockCheckToken && fulfillAll.value) {
fulfillAll.value = false;
return;
}
if (token === stockCheckToken && fulfillAll.value && totalAmount.value !== 0) {
receiveFullPayment.value = true;
}
} finally {
if (token === stockCheckToken) {
isStockChecking.value = false;
}
}
});
@@ -465,6 +492,37 @@ function resolveTimestamp(value: string): number | undefined {
function buildAttachments(source: PhotoItem[] = photoItems.value): Partial<Attachment>[] {
return source.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
}
async function validateDirectStock(): Promise<boolean> {
stockPreviewList.value = [];
const requirementList = mergeProductStockRequirements(items.value.flatMap(item => {
const sku = getSku(item);
const unit = getUnit(item);
const quantity = normalizePackageQuantity(item.quantity ?? "");
const baseQuantity = quantity && unit ? toPackageBaseQuantity(quantity, unit.baseQuantityPerUnit) : undefined;
if (!item.skuId || !sku?.stockEnabled || !baseQuantity) {
return [];
}
return [{ skuId: item.skuId, baseQuantity, productName: getProduct(item)?.name || item.productName || "未命名产品", unitName: "基础单位", stockEnabled: sku.stockEnabled }];
}));
if (!requirementList.length) {
return true;
}
try {
const stockList = await ProductAPI.stockBatchDetail({ skuIdList: requirementList.map(item => item.skuId) });
stockPreviewList.value = buildProductStockPreview(stockList, requirementList);
const shortageMessage = getProductStockShortageMessage(stockList, requirementList);
if (shortageMessage) {
Message.warning(`${shortageMessage},不能直接履约`);
return false;
}
return true;
} catch (error) {
stockPreviewList.value = [];
Message.error(getErrorMessage(error, "库存查询失败,暂不能直接履约"));
return false;
}
}
function validate(): boolean {
if (!customer.value?.id) { Message.warning("请选择客户"); return false; }
if (!items.value.length) { Message.warning("请至少添加一项产品"); return false; }
@@ -488,7 +546,12 @@ function validate(): boolean {
return true;
}
async function save(): Promise<void> {
if (isSaving.value || !validate()) return;
if (isSaving.value) return;
if (isStockChecking.value) {
Message.warning("库存正在预检,请稍候再提交");
return;
}
if (!validate()) return;
if (paymentVerified.value || receiveFullPayment.value || fulfillAll.value) {
const dialog = DialogPlugin.confirm({
title: "确认直通完成交易",
@@ -515,6 +578,7 @@ async function submitOrder(): Promise<void> {
if (!requestNo.value) requestNo.value = createPackageRequestNo();
isSaving.value = true;
try {
if (fulfillAll.value && !await validateDirectStock()) return;
const itemList = items.value.map((item, index) => ({
skuId: item.skuId!,
skuUnitId: item.skuUnitId!,
@@ -600,6 +664,10 @@ onUnmounted(() => navBarStore.clearRight());
}
}
.tips {
font-size: .875rem;
}
.product-part {
> .header {
@@ -707,16 +775,12 @@ onUnmounted(() => navBarStore.clearRight());
.textarea {
width: 100%;
}
.photo-part {
padding: 1rem;
}
}
.submit-wrap {
gap: 1rem;
left: 2rem;
right: 2rem;
left: 1.5rem;
right: 1.5rem;
border: 1px solid rgba(0, 0, 0, .05);
bottom: calc(env(safe-area-inset-bottom, 0) + 1rem);
z-index: 12;
+14 -10
View File
@@ -96,8 +96,10 @@
</div>
<div class="main">
<div class="title">
<span class="name text-bold" v-text="item.productNameSnapshot || '未命名产品'" />
<t-tag v-if="item.gifted" size="small" theme="success" content="赠送" />
<div class="name-gift">
<span class="name text-bold" v-text="item.productNameSnapshot || '未命名产品'" />
<t-tag v-if="item.gifted" size="small" theme="success" content="赠送" />
</div>
<t-tag
:content="getItemFulfillmentStatus(item).text"
:theme="getItemFulfillmentStatus(item).theme"
@@ -111,13 +113,6 @@
v-text="`¥${formatPackageAmount(item.lineAmount)}`"
/>
<t-tag v-else size="small" content="套餐权益" />
<t-tag
v-if="item.skuCodeSnapshot"
size="small"
theme="primary"
variant="light"
:content="item.skuCodeSnapshot"
/>
<product-sku-snapshot-tags
:product-name="item.productNameSnapshot"
:snapshot="item.skuSpecSnapshot"
@@ -464,7 +459,10 @@ async function toFulfillmentList(): Promise<void> {
async function toCreateFulfillment(): Promise<void> {
if (order.value?.id) await router.push({
path: "/management/order-fulfillments/create",
query: { salesOrderId: order.value.id }
query: {
salesOrderId: order.value.id,
transitionDepth: String(getCurrentRouteDepth() + 1)
}
});
}
@@ -628,6 +626,12 @@ onUnmounted(() => navBarStore.clearRight());
display: flex;
align-items: center;
justify-content: space-between;
.name-gift {
gap: .5rem;
display: flex;
align-items: center;
}
}
.description {
+24 -6
View File
@@ -15,7 +15,7 @@
<t-form
v-if="order"
class="form"
label-width="6rem"
label-width="7rem"
v-entrance-transition="70"
>
<t-form-item label="收款金额" required-mark>
@@ -31,8 +31,9 @@
size="extra-small"
theme="primary"
variant="outline"
content="全额收款"
@click="fillUnpaidAmount"
>全额收款</t-button>
/>
</template>
</t-input>
</t-form-item>
@@ -55,6 +56,12 @@
/>
</div>
</t-form-item>
<t-form-item v-if="canDirectVerify" label="直接核销收款" content-align="right">
<t-switch v-model="directVerify" size="small" />
<template #help v-if="directVerify">
<span class="red">直接核销后,收款会立即计入订单已收款,不再进入待核销队列。</span>
</template>
</t-form-item>
<t-form-item label="备注">
<t-textarea
v-model="remark"
@@ -95,7 +102,7 @@ import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, formatPackageAmount, formatPackageAmountInput, parsePackageAmount } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo";
defineOptions({ name: "SalesOrderPayment" });
@@ -114,6 +121,10 @@ const remark = ref("");
const photoItems = ref<PhotoItem[]>([]);
const photoUrlList = computed(() => photoItems.value.map(item => item.url));
const requestNo = ref("");
const directVerify = ref(false);
const canDirectVerify = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager)
&& userStore.hasPermissions(permissionCodes.fundReceive.create)
&& userStore.hasPermissions(permissionCodes.fundReceive.verify));
function openOccurredAtPicker(): void {
draftTime.value = occurredAtText.value || Time.formatDateTime(new Date());
@@ -162,6 +173,10 @@ async function loadData(): Promise<void> {
async function save(): Promise<void> {
if (saving.value || !order.value?.id) return;
if (directVerify.value && !canDirectVerify.value) {
Message.warning("当前账号没有店长直接核销权限");
return;
}
const amount = parsePackageAmount(amountText.value);
const attachmentList = buildAttachments();
if (amount === undefined || amount === 0) {
@@ -175,15 +190,18 @@ async function save(): Promise<void> {
if (!requestNo.value) requestNo.value = createPackageRequestNo();
saving.value = true;
try {
const record = await SalesOrderAPI.receivePayment({
const request = {
salesOrderId: order.value.id,
requestNo: requestNo.value,
amount,
occurredAt: resolveTimestamp(occurredAtText.value),
attachmentList,
remark: remark.value.trim() || null
});
Message.success("收款登记已提交");
};
const record = directVerify.value
? await SalesOrderAPI.receivePaymentAndVerify(request)
: await SalesOrderAPI.receivePayment(request);
Message.success(directVerify.value ? "收款登记并核销成功" : "收款登记已提交");
await router.replace(record.id && userStore.hasPermissions(permissionCodes.fundReceive.create)
? `/management/fund-records/${record.id}`
: `/management/sales-orders/${order.value.id}`);
+54 -43
View File
@@ -5,10 +5,10 @@
<h1 v-text="appName" />
<p v-text="appDescription" />
</section>
<t-cell-group theme="card" v-entrance-transition="70">
<t-cell-group v-if="agreementItemList.length" theme="card" v-entrance-transition="70">
<t-cell
v-for="item in agreementItemList"
:key="item.code"
:key="item.id"
:title="item.title"
arrow
hover
@@ -19,6 +19,10 @@
</template>
</t-cell>
</t-cell-group>
<t-empty
v-else
:description="isLoading ? '正在加载协议配置' : '暂无可用协议'"
/>
<footer class="footer" v-entrance-transition="140">
<p>技术支持:夜雨</p>
<p>联系技术:imyeyu@qq.com</p>
@@ -29,17 +33,14 @@
</template>
<script setup lang="ts">
import { type Setting, SettingAPI } from "timi-web-api";
import { SettingAPI } from "timi-web-api";
import Logo from "@/pages/user/component/Logo.vue";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
type AgreementCode = "SERVICE_LICENSE" | "PRIVACY_LICENSE" | "OPEN_SOURCE_LICENSE";
type AgreementRouteName = "ServiceAgreement" | "PrivacyAgreement" | "OpenSourceAgreement";
type AgreementItem = {
code: AgreementCode;
icon: string;
routeName: AgreementRouteName;
id: string;
title: string;
};
@@ -51,53 +52,63 @@ const appName = "糕店客户管理";
const appDescription = "面向门店的客户、登记、积分与经营管理工具";
const router = useRouter();
const agreementItemList: AgreementItem[] = [
{
code: "SERVICE_LICENSE",
icon: "file-attachment",
routeName: "ServiceAgreement",
title: "服务协议"
},
{
code: "PRIVACY_LICENSE",
icon: "secured",
routeName: "PrivacyAgreement",
title: "隐私协议"
},
{
code: "OPEN_SOURCE_LICENSE",
icon: "code",
routeName: "OpenSourceAgreement",
title: "开源软件协议"
}
];
const agreementCodeList = agreementItemList.map(item => item.code);
const settingMap = ref<Record<string, Setting>>({});
function getAgreementId(code: AgreementCode): string {
return settingMap.value[code]?.value?.trim() || "";
}
const agreementItemList = ref<AgreementItem[]>([]);
const isLoading = ref(false);
async function openAgreement(item: AgreementItem): Promise<void> {
const articleId = getAgreementId(item.code);
if (!articleId) {
Message.warning(`${item.title}暂未配置`);
return;
}
await router.push({
name: item.routeName,
name: "Agreement",
params: {
id: articleId
id: item.id
}
});
}
async function loadSettings(): Promise<void> {
function normalizeAgreementItem(value: unknown): AgreementItem | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const source = value as Record<string, unknown>;
const id = typeof source.id === "string" ? source.id.trim() : "";
const title = typeof source.title === "string" ? source.title.trim() : "";
const icon = typeof source.icon === "string" ? source.icon.trim() : "";
if (!id || !title) {
return undefined;
}
return {
id,
title,
icon: icon || "file-attachment"
};
}
function parseAgreementList(value?: string): AgreementItem[] {
if (!value?.trim()) {
return [];
}
try {
settingMap.value = await SettingAPI.map("GAO_SYSTEM", undefined, agreementCodeList);
const sourceList: unknown = JSON.parse(value);
if (!Array.isArray(sourceList)) {
return [];
}
return sourceList
.map(item => normalizeAgreementItem(item))
.filter((item): item is AgreementItem => !!item);
} catch {
Message.error("协议配置格式错误");
return [];
}
}
async function loadSettings(): Promise<void> {
isLoading.value = true;
try {
const settingMap = await SettingAPI.map("GAO_SYSTEM", undefined, ["AGREEMENT"]);
agreementItemList.value = parseAgreementList(settingMap.AGREEMENT?.value);
} catch (error) {
Message.error(getErrorMessage(error, "协议配置加载失败"));
} finally {
isLoading.value = false;
}
}
+6
View File
@@ -16,6 +16,7 @@
</template>
<script setup lang="ts">
import { onBeforeRouteLeave } from "vue-router";
import { type Article, ArticleAPI, AttachmentAPI } from "timi-web-api";
import { ArticleView } from "timi-tdesign-mobile";
import { useNavBarStore } from "@/store/navBarStore";
@@ -70,6 +71,11 @@ async function loadArticle(): Promise<void> {
watch(() => route.params.id, async () => {
await loadArticle();
}, { immediate: true });
onBeforeRouteLeave(() => {
loadToken += 1;
navBarStore.setTitle(getRouteNavBarTitle());
});
</script>
<style scoped lang="less">
+10 -18
View File
@@ -36,15 +36,16 @@
</template>
</t-cell>
</t-cell-group>
<div v-if="!isCreateMode && !isStoreManagerScope" class="actions" v-entrance-transition="140">
<t-button
v-can="permissionCodes.store.delete"
class="action"
theme="danger"
:loading="isRemoving"
@click="confirmRemoveStore"
>删除</t-button>
</div>
<t-button
v-if="!isCreateMode && !isStoreManagerScope"
v-can="permissionCodes.store.delete"
class="cell-button"
theme="danger"
:loading="isRemoving"
content="删除"
v-entrance-transition="140"
@click="confirmRemoveStore"
/>
<store-remove-dialog
v-model:visible="removeDialogVisible"
:store="formData"
@@ -213,15 +214,6 @@ async function removeStore(id: string): Promise<void> {
.links {
margin: 1rem 0;
}
.actions {
margin: 0 1rem;
display: flex;
.action {
flex: 1;
}
}
}
</style>
+70 -35
View File
@@ -5,41 +5,60 @@
:key="group.title"
class="entry-group"
>
<h2 v-text="group.title" />
<h2 v-text="group.title"/>
<div class="items" :class="group.layout">
<div
v-entrance-transition="index * 70"
v-for="(item, index) in group.entryList"
:key="item.path"
class="item"
:class="{ quick: item.path === '/customer/quick-sign-in' }"
@click="go(item.path)"
class="item-wrap"
v-entrance-transition="index * 70"
>
<div class="body">
<t-icon :name="item.icon" size="1.75rem" />
<h2 v-text="item.title" />
</div>
<t-button
class="item"
:class="{ quick: item.path === '/customer/quick-sign-in' }"
variant="text"
@click="router.push(item.path)"
>
<div class="body">
<t-icon :name="item.icon" size="1.75rem"/>
<h2 v-text="item.title"/>
</div>
</t-button>
</div>
</div>
</section>
<t-button
class="help"
theme="primary"
variant="text"
content="使用帮助"
@click="go('/management/help')"
>
<template #icon>
<t-icon name="help-circle" />
</template>
</t-button>
<div class="help-wrap" v-entrance-transition="visibleEntryCount * 70">
<t-button
class="help"
theme="primary"
variant="text"
content="使用帮助"
@click="router.push('/management/help')"
>
<template #icon>
<t-icon name="help-circle"/>
</template>
</t-button>
<t-button
v-if="canUseAi"
class="ai"
theme="primary"
variant="text"
content="AI 小糕"
@click="openAiChat"
>
<template #icon>
<t-icon name="robot-2" />
</template>
</t-button>
</div>
</div>
</template>
<script setup lang="ts">
import { NotifyAPI, useUserStore } from "timi-web-api";
import { useNavBarStore } from "@/store/navBarStore";
import { corePermissionCodes, permissionCodes } from "@/utils/permission";
import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
name: "HomeTab"
@@ -126,14 +145,13 @@ const visibleEntryGroupList = computed(() => entryGroupList
entryList: group.entryList.filter(item => userStore.hasPermissions(item.permission))
}))
.filter(group => group.entryList.length));
const visibleEntryCount = computed(() => visibleEntryGroupList.value
.reduce((count, group) => count + group.entryList.length, 0));
const canReadNotify = computed(() => userStore.hasPermissions(corePermissionCodes.notify.read));
const canUseAi = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager));
async function go(path: string): Promise<void> {
await router.push(path);
}
async function goNotifyList(): Promise<void> {
await router.push("/notify");
async function openAiChat(): Promise<void> {
await router.push("/ai/chat");
}
async function loadUnreadNotifyCount(): Promise<void> {
@@ -151,7 +169,9 @@ async function loadUnreadNotifyCount(): Promise<void> {
onMounted(async () => {
navBarStore.clearRight();
if (canReadNotify.value) {
navBarStore.setRightIcon("notification", goNotifyList);
navBarStore.setRightIcon("notification", async () => {
await router.push("/notify");
});
navBarStore.setRightBadge(0);
await loadUnreadNotifyCount();
}
@@ -165,7 +185,7 @@ onUnmounted(() => {
<style scoped lang="less">
.home-tab {
gap: 2rem;
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
flex-direction: column;
@@ -184,15 +204,21 @@ onUnmounted(() => {
}
.items {
--td-button-border-radius: 1.75rem;
gap: .875rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
.item {
--entry-color: #0f766e;
--entry-color-active: #0a5d56;
--entry-background: rgba(255, 255, 255, .92);
color: var(--entry-color);
width: 100%;
height: auto;
margin: 0;
padding: 1rem;
display: flex;
position: relative;
@@ -201,11 +227,12 @@ onUnmounted(() => {
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
align-items: center;
aspect-ratio: 1;
border-radius: 1.4rem;
flex-direction: column;
justify-content: center;
&.quick {
--td-button-default-text-active-bg-color: var(--entry-color-active);
color: var(--entry-background);
background: var(--entry-color);
}
@@ -229,13 +256,21 @@ onUnmounted(() => {
}
}
.help {
.help-wrap {
--td-button-border-radius: 1rem;
width: 8rem;
margin: 0 auto;
background: white;
box-shadow: var(--td-shadow-3);
gap: 1rem;
width: fit-content;
margin: 1rem auto 0 auto;
display: flex;
align-items: center;
.help,
.ai {
width: 8rem;
background: white;
box-shadow: var(--td-shadow-3);
}
}
}
+5
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, type RouteLocationNormalized } from "vue-router";
import RootLayout from "@/layout/RootLayout.vue";
import aiRoutes from "@/router/routes/aiRoutes";
import chartRoutes from "@/router/routes/chartRoutes";
import customerRoutes from "@/router/routes/customerRoutes";
import { managementAccountRoutes, managementSettingRoutes, storeRoutes } from "@/router/routes/managementRoutes";
@@ -92,6 +93,7 @@ const router = createRouter({
},
children: [
...tabRoutes,
...aiRoutes,
...detailRoutes
]
}
@@ -130,6 +132,9 @@ router.beforeEach(async (to) => {
async function resolveStoreAccessRedirect(to: RouteLocationNormalized): Promise<string | undefined> {
const userStore = useUserStore();
const routeName = String(to.name || "");
if (routeName === "AiChat" && !userStore.hasRoles(gaoRoleCodes.storeManager)) {
return "/management";
}
const storeDiaryRouteNameList = ["StoreDiaryList", "StoreDiaryCreate", "StoreDiaryDetail", "StoreDiaryEdit"];
if (storeDiaryRouteNameList.includes(routeName)) {
if (!userStore.hasRoles(gaoRoleCodes.storeManager)) {
+20
View File
@@ -0,0 +1,20 @@
import type { RouteRecordRaw } from "vue-router";
const aiRoutes: RouteRecordRaw[] = [
{
path: "/ai/chat",
name: "AiChat",
component: () => import("@/pages/ai/AiChatPage.vue"),
meta: {
depth: 3,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "AI 小糕",
tabBarVisible: false,
bodyBackground: "#F5F5F8",
darkBodyBackground: "#10161f"
}
}
];
export default aiRoutes;
+14
View File
@@ -173,6 +173,20 @@ export const managementSettingRoutes: RouteRecordRaw[] = [
darkBodyBackground: "#10161f"
}
},
{
path: "/management/about/agreement/:id",
name: "Agreement",
component: () => import("@/pages/setting/Agreement.vue"),
meta: {
depth: 4,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "协议",
tabBarVisible: false,
bodyBackground: "#FFF",
darkBodyBackground: "#10161f"
}
},
{
path: "/management/about/service-agreement/:id",
name: "ServiceAgreement",
+1
View File
@@ -52,6 +52,7 @@ export type EventRecordListItemView = {
export type EventRecordPageResult = PageResult<EventRecordListItemView> & {
numberValueTotal?: number | null;
customerCount?: number | null;
};
export type EventRecordPage = {
+14
View File
@@ -1,4 +1,5 @@
import type { Attachment, Model, PageResult, User } from "timi-web-api";
import type { Customer } from "@/types/Customer";
export type ProductType = "SERVICE" | "GOODS" | "CONSUMABLE";
export type ProductAttachmentType = "PHOTO" | "OTHER";
@@ -93,6 +94,12 @@ export type Product = {
attachmentList?: Attachment[];
} & Model;
export type ProductSelection = {
product: Product;
sku: ProductSku;
unit: ProductSkuUnit;
};
export type ProductCategoryPage = {
storeId?: string;
status?: ProductCategoryStatus;
@@ -126,6 +133,7 @@ export type ProductStockProduct = {
productId: string;
productCode: string;
productName: string;
attachmentList?: Attachment[];
productType?: ProductType;
skuCount?: number;
onHandQuantity?: number | string;
@@ -141,6 +149,11 @@ export type ProductStockPage = {
size: number;
};
export type ProductStockBatchRequest = {
storeId?: string;
skuIdList: string[];
};
export type StockRecord = {
storeId?: string;
skuId?: string;
@@ -172,6 +185,7 @@ export type StockRecord = {
remark?: string | null;
operator?: Partial<User>;
attachmentList?: Partial<Attachment>[];
customer?: Customer;
} & Model;
export type StockRecordPage = {
+99
View File
@@ -0,0 +1,99 @@
import { Time } from "timi-web";
/** 日期筛选器支持的预设范围。 */
export type DateFilterPreset =
| "all"
| "today"
| "yesterday"
| "lastWeek"
| "thisMonth"
| "lastMonth"
| "last7"
| "last30";
/** 日期时间范围,文本格式为 YYYY-MM-DD HH:mm。 */
export type DateTimeRange = [string, string];
/**
* 根据预设筛选类型生成日期时间范围。
*
* `last7` 和 `last30` 为包含今天在内的最近 7 天和 30 天。
*/
export function buildPresetDateRange(type: DateFilterPreset, now = new Date()): DateTimeRange {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = getDateStart(now);
const day = current.getDay() || 7;
const thisWeekMonday = addDays(current, 1 - day);
const lastWeekMonday = addDays(thisWeekMonday, -7);
const lastWeekSunday = addDays(thisWeekMonday, -1);
return buildDayRange(lastWeekMonday, lastWeekSunday);
}
if (type === "thisMonth") {
const current = new Date(now);
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
return buildDayRange(begin, current);
}
if (type === "lastMonth") {
const current = new Date(now);
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
const end = new Date(current.getFullYear(), current.getMonth(), 0);
return buildDayRange(begin, end);
}
if (type === "last7" || type === "last30") {
const end = new Date(now);
end.setHours(23, 59, 0, 0);
const begin = new Date(end);
begin.setDate(begin.getDate() - (type === "last7" ? 6 : 29));
begin.setHours(0, 0, 0, 0);
return [Time.formatDateTime(begin), Time.formatDateTime(end)];
}
const date = getDateStart(now);
date.setDate(date.getDate() - (type === "today" ? 0 : 1));
return buildDayRange(date, date);
}
/** 将路由或组件输入转换为合法的日期筛选类型。 */
export function parseDateFilterType<T extends string>(
value: unknown,
options: readonly { value: T }[],
fallback: T
): T {
const text = Array.isArray(value) ? String(value[0] || "") : String(value || "");
return options.some(item => item.value === text) ? text as T : fallback;
}
/** 将日期时间文本解析为本地时间。空开始时间和空结束时间分别回退到 Unix 起点和当前时间。 */
export function parseDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
function buildDayRange(begin: Date, end: Date): DateTimeRange {
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
}
function getDateStart(date: Date): Date {
const value = new Date(date);
value.setHours(0, 0, 0, 0);
return value;
}
function addDays(date: Date, days: number): Date {
const value = new Date(date);
value.setDate(value.getDate() + days);
return value;
}
+15
View File
@@ -85,6 +85,21 @@ export function convertPackageQuantity(quantity: number | string, fromBaseQuanti
return formatDecimal(numerator / denominator, 3, 3);
}
/** 将基础单位数量换算为业务单位展示数量,结果最多保留三位小数。 */
export function convertBaseQuantityToPackageQuantity(baseQuantity: number | string, baseQuantityPerUnit: number | string): string | undefined {
const quantityParts = toDecimalParts(baseQuantity, 3);
const unitParts = toDecimalParts(baseQuantityPerUnit, 6);
if (!quantityParts || !unitParts || unitParts.value === 0n) {
return undefined;
}
const numerator = quantityParts.value * 10n ** BigInt(unitParts.scale + 3);
const denominator = 10n ** BigInt(quantityParts.scale) * unitParts.value;
const quotient = numerator / denominator;
const remainder = numerator % denominator;
const rounded = denominator < remainder * 2n ? quotient + 1n : quotient;
return formatDecimal(rounded, 3, 3);
}
/** 精确相加两个最多三位小数的数量。 */
export function addPackageQuantity(left: number | string, right: number | string): string | undefined {
const leftParts = toQuantityParts(left);
+86
View File
@@ -0,0 +1,86 @@
import type { ProductStock } from "@/types/Product";
import { addPackageQuantity, comparePackageQuantity } from "@/utils/package";
export type ProductStockRequirement = {
skuId: string;
baseQuantity: string;
productName: string;
unitName: string;
stockEnabled: boolean;
};
export type ProductStockPreviewItem = ProductStockRequirement & {
availableQuantity: string;
shortageQuantity?: string;
};
/** 合并同一 SKU 的基础库存需求,避免多单位订单拆开校验造成误判。 */
export function mergeProductStockRequirements(requirementList: ProductStockRequirement[]): ProductStockRequirement[] {
const requirementMap = new Map<string, ProductStockRequirement>();
for (const requirement of requirementList) {
const existing = requirementMap.get(requirement.skuId);
if (!existing) {
requirementMap.set(requirement.skuId, { ...requirement });
continue;
}
const baseQuantity = addPackageQuantity(existing.baseQuantity, requirement.baseQuantity);
if (baseQuantity) {
existing.baseQuantity = baseQuantity;
}
}
return [...requirementMap.values()];
}
/** 返回库存不足提示;库存单位统一为 SKU 基础单位。 */
export function getProductStockShortageMessage(stockList: ProductStock[], requirementList: ProductStockRequirement[]): string | undefined {
const stockMap = new Map(stockList.map(stock => [stock.skuId, stock]));
const shortageList: string[] = [];
for (const requirement of requirementList) {
if (!requirement.stockEnabled) {
continue;
}
const availableQuantity = stockMap.get(requirement.skuId)?.availableQuantity ?? "0";
if (!(0 < comparePackageQuantity(requirement.baseQuantity, availableQuantity))) {
continue;
}
const shortageQuantity = subtractPackageQuantity(requirement.baseQuantity, availableQuantity);
shortageList.push(`${requirement.productName} 可用 ${availableQuantity} ${requirement.unitName},缺少 ${shortageQuantity} ${requirement.unitName}`);
}
return shortageList.length ? `库存不足:${shortageList.join(";")}` : undefined;
}
/** 生成启用库存管理 SKU 的库存预检明细。 */
export function buildProductStockPreview(stockList: ProductStock[], requirementList: ProductStockRequirement[]): ProductStockPreviewItem[] {
const stockMap = new Map(stockList.map(stock => [stock.skuId, stock]));
return requirementList.filter(requirement => requirement.stockEnabled).map(requirement => {
const availableQuantity = String(stockMap.get(requirement.skuId)?.availableQuantity ?? "0");
const shortageQuantity = 0 < comparePackageQuantity(requirement.baseQuantity, availableQuantity)
? subtractPackageQuantity(requirement.baseQuantity, availableQuantity)
: undefined;
return { ...requirement, availableQuantity, shortageQuantity };
});
}
function subtractPackageQuantity(left: string, right: number | string): string {
const leftParts = toQuantityParts(left);
const rightParts = toQuantityParts(right);
if (!leftParts || !rightParts) {
return left;
}
const scale = Math.max(leftParts.scale, rightParts.scale);
const result = leftParts.value * 10n ** BigInt(scale - leftParts.scale)
- rightParts.value * 10n ** BigInt(scale - rightParts.scale);
const text = result.toString().padStart(scale + 1, "0");
const integerText = scale ? text.slice(0, -scale) : text;
const fractionText = scale ? text.slice(-scale).replace(/0+$/, "") : "";
return fractionText ? `${integerText}.${fractionText}` : integerText;
}
function toQuantityParts(value: number | string): { value: bigint; scale: number } | undefined {
const text = String(value).trim();
if (!/^\d+(?:\.\d{1,3})?$/.test(text)) {
return undefined;
}
const [integerText, fractionText = ""] = text.split(".");
return { value: BigInt(`${integerText}${fractionText}`), scale: fractionText.length };
}