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> { async function create(request: OrderFulfillmentCreateRequest): Promise<OrderFulfillment> {
return await axios.post(`${URI}/create`, request); 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> { async function prepareStock(request: FulfillmentPrepareStockRequest): Promise<OrderFulfillment> {
return await axios.post(`${URI}/prepare-stock`, request); 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); 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, ProductSpec,
ProductSpecValue, ProductSpecValue,
ProductStock, ProductStock,
ProductStockBatchRequest,
ProductStockProduct, ProductStockProduct,
ProductStockChangeRequest, ProductStockChangeRequest,
ProductStockManualRevokeRequest, 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> { async function initialIn(request: ProductStockChangeRequest): Promise<ProductStock> {
return await axios.post(`${STOCK_URI}/initial-in`, withCurrentStoreId(request)); return await axios.post(`${STOCK_URI}/initial-in`, withCurrentStoreId(request));
} }
@@ -161,6 +166,7 @@ export default {
stockList, stockList,
stockProductList, stockProductList,
stockDetail, stockDetail,
stockBatchDetail,
initialIn, initialIn,
manualIn, manualIn,
manualOut, manualOut,
+4
View File
@@ -37,6 +37,9 @@ async function cancel(request: SalesOrderCancelRequest): Promise<SalesOrder> {
async function receivePayment(request: OrderPaymentReceiveRequest): Promise<FundRecord> { async function receivePayment(request: OrderPaymentReceiveRequest): Promise<FundRecord> {
return await axios.post(`${ORDER_URI}/payment/receive`, request); 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> { async function verifyPayment(id: string): Promise<FundRecord> {
return await axios.post(`${ORDER_URI}/payment/verify`, undefined, { params: { id } }); return await axios.post(`${ORDER_URI}/payment/verify`, undefined, { params: { id } });
} }
@@ -77,6 +80,7 @@ export default {
update, update,
cancel, cancel,
receivePayment, receivePayment,
receivePaymentAndVerify,
verifyPayment, verifyPayment,
rejectPayment, rejectPayment,
cancelPayment, cancelPayment,
+7 -1
View File
@@ -92,6 +92,11 @@ html.theme-dark body {
gap: 1rem; gap: 1rem;
} }
.t-pull-down-refresh {
overflow-x: hidden;
overflow-y: auto;
}
.t-dropdown-menu { .t-dropdown-menu {
&:after { &:after {
@@ -111,9 +116,10 @@ html.theme-dark body {
.center-tips { .center-tips {
color: var(--tui-gray); color: var(--tui-gray);
margin: 0 auto;
padding: .75rem; padding: .75rem;
text-align: center;
font-size: .875rem; font-size: .875rem;
text-align: center;
} }
.avatar { .avatar {
+10 -2
View File
@@ -123,7 +123,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile"; import { BottomPopup } from "timi-tdesign-mobile";
import ProductAPI from "@/api/ProductAPI"; 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 { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { formatProductAmount, getProductPhotoURL, getProductSkuSpecEntries } from "@/utils/product"; import { formatProductAmount, getProductPhotoURL, getProductSkuSpecEntries } from "@/utils/product";
@@ -137,7 +137,10 @@ interface Props {
} }
const props = withDefaults(defineProps<Props>(), { label: "产品", requiredMark: false, placeholder: "请选择产品 SKU" }); 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 visible = ref(false);
const loading = ref(false); const loading = ref(false);
const keyword = ref(""); const keyword = ref("");
@@ -242,6 +245,11 @@ function selectUnit(sku: ProductSku, skuUnitId: string): void {
if (!sku.id || !skuUnitId) { if (!sku.id || !skuUnitId) {
return; 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 }); emit("update:modelValue", { skuId: sku.id, skuUnitId });
close(); close();
} }
@@ -8,7 +8,6 @@
variant="light-outline" variant="light-outline"
:content="tag.content" :content="tag.content"
/> />
<t-tag v-if="unitText" size="small" :content="unitText" />
</div> </div>
</template> </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>
+24 -10
View File
@@ -98,7 +98,6 @@
<t-input v-model.trim="formData.telephone" type="tel" placeholder="手机号" borderless :disabled="isCustomerBound" /> <t-input v-model.trim="formData.telephone" type="tel" placeholder="手机号" borderless :disabled="isCustomerBound" />
</t-form-item> </t-form-item>
<t-form-item label="介绍人"> <t-form-item label="介绍人">
<div class="referrer">
<div class="selector"> <div class="selector">
<t-input <t-input
:value="formData.introducerCustomer?.name" :value="formData.introducerCustomer?.name"
@@ -108,6 +107,14 @@
:disabled="isCustomerBound" :disabled="isCustomerBound"
/> />
<div class="actions"> <div class="actions">
<t-button
v-if="introducerCustomerId"
variant="text"
theme="danger"
size="extra-small"
:disabled="isCustomerBound"
@click="clearIntroducer"
>清除</t-button>
<t-button <t-button
theme="primary" theme="primary"
variant="outline" variant="outline"
@@ -117,12 +124,12 @@
>选择客户</t-button> >选择客户</t-button>
</div> </div>
</div> </div>
</t-form-item>
<t-form-item v-if="formData.introducerCustomer" label-width="0">
<customer-profile-card <customer-profile-card
v-if="formData.introducerCustomer" class="introducer-profile"
class="profile"
:customer="formData.introducerCustomer" :customer="formData.introducerCustomer"
/> />
</div>
</t-form-item> </t-form-item>
<t-form-item label="调理部位" :arrow="!isCustomerBound" @click="openBodyAreaPicker"> <t-form-item label="调理部位" :arrow="!isCustomerBound" @click="openBodyAreaPicker">
<div class="picker-text" v-text="selectedAreaText" /> <div class="picker-text" v-text="selectedAreaText" />
@@ -332,6 +339,15 @@ async function handleIntroducerChange(id: string): Promise<void> {
formData.introducerCustomer = customer; formData.introducerCustomer = customer;
} }
function clearIntroducer(): void {
if (isCustomerBound.value) {
return;
}
introducerCustomerId.value = "";
formData.introducerCustomerId = "";
formData.introducerCustomer = undefined;
}
// ---------- 表单 ---------- // ---------- 表单 ----------
const formData = reactive<Customer>({ const formData = reactive<Customer>({
@@ -743,15 +759,9 @@ function buildAttachmentList(): Attachment[] {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.referrer {
gap: .75rem;
display: flex;
flex-direction: column;
.selector { .selector {
gap: .75rem; gap: .75rem;
display: flex; display: flex;
}
.actions { .actions {
gap: .5rem; gap: .5rem;
@@ -760,6 +770,10 @@ function buildAttachmentList(): Attachment[] {
} }
} }
.introducer-profile {
flex: 1;
}
.sex { .sex {
gap: .5rem; gap: .5rem;
display: flex; display: flex;
+3 -49
View File
@@ -168,6 +168,7 @@ import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { NavBarAction } from "@/types/NavBarAction"; import type { NavBarAction } from "@/types/NavBarAction";
import type { Tag } from "@/types/Tag"; import type { Tag } from "@/types/Tag";
import { buildPresetDateRange, parseDateTime } from "@/utils/date";
import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({ defineOptions({
@@ -206,7 +207,7 @@ const dateOptions: Array<{ label: string; value: DateFilterType }> = [
{ label: "自定义范围", value: "custom" } { label: "自定义范围", value: "custom" }
]; ];
const dateFilterType = ref<DateFilterType>("all"); const dateFilterType = ref<DateFilterType>("all");
const dateRange = ref<[string, string]>(buildPresetRange("all")); const dateRange = ref<[string, string]>(buildPresetDateRange("all"));
const startDraft = ref(""); const startDraft = ref("");
const endDraft = ref(""); const endDraft = ref("");
const startVisible = ref(false); const startVisible = ref(false);
@@ -279,7 +280,7 @@ async function selectDateFilter(value: DropdownValue): Promise<void> {
dateFilterType.value = String(value) as DateFilterType; dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") { if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value); dateRange.value = buildPresetDateRange(dateFilterType.value);
await refresh(); 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> </script>
<style scoped lang="less"> <style scoped lang="less">
+1 -1
View File
@@ -9,7 +9,7 @@
@refresh="refresh" @refresh="refresh"
@scrolltolower="fetch" @scrolltolower="fetch"
> >
<t-cell-group theme="card"> <t-cell-group class="fix-last-cell-line" theme="card">
<t-swipe-cell <t-swipe-cell
v-entrance-transition="index * 30" v-entrance-transition="index * 30"
v-for="(item, index) in visibleEventList" v-for="(item, index) in visibleEventList"
+11 -60
View File
@@ -73,7 +73,8 @@
/> />
<t-cell title="事件" :note="eventFilterLabel" /> <t-cell title="事件" :note="eventFilterLabel" />
<t-cell v-if="optionFilterLabel !== '全部选项'" title="选项" :note="optionFilterLabel" /> <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 v-if="summaryNumberValueText" title="数值合计" :note="summaryNumberValueText" />
</t-cell-group> </t-cell-group>
<t-cell-group v-if="hasSelectedOption && optionStatList.length" title="选项统计" class="option-stat" theme="card"> <t-cell-group v-if="hasSelectedOption && optionStatList.length" title="选项统计" class="option-stat" theme="card">
@@ -136,7 +137,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownValue } from "tdesign-mobile-vue"; 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 { useUserStore } from "timi-web-api";
import EventAPI from "@/api/EventAPI"; import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI"; import EventRecordAPI from "@/api/EventRecordAPI";
@@ -148,6 +149,7 @@ import type { EventOptionStat, EventRecordPage, EventRecordPageResult, Verificat
import type { NavBarAction } from "@/types/NavBarAction"; import type { NavBarAction } from "@/types/NavBarAction";
import { isRetiredEvent } from "@/utils/event"; import { isRetiredEvent } from "@/utils/event";
import { formatEventNumberValue } from "@/utils/eventValue"; import { formatEventNumberValue } from "@/utils/eventValue";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
import { Message } from "@/utils/message.ts"; import { Message } from "@/utils/message.ts";
@@ -178,7 +180,7 @@ const dateOptions: Array<{ label: string; value: DateFilterType }> = [
const keyword = ref(getInitialKeyword()); const keyword = ref(getInitialKeyword());
const customerId = ref(getQueryText("customerId")); const customerId = ref(getQueryText("customerId"));
const customerSelectorVisible = ref(false); 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 dateRange = ref(parseDateRange(dateFilterType.value));
const startDraft = ref(""); const startDraft = ref("");
const endDraft = ref(""); const endDraft = ref("");
@@ -364,7 +366,8 @@ const page = reactive<EventRecordPage>({
const pageResult = reactive<EventRecordPageResult>({ const pageResult = reactive<EventRecordPageResult>({
total: 0, total: 0,
list: [], list: [],
numberValueTotal: null numberValueTotal: null,
customerCount: 0
}); });
const optionStatList = ref<EventOptionStat[]>([]); const optionStatList = ref<EventOptionStat[]>([]);
let loadToken = 0; let loadToken = 0;
@@ -389,6 +392,7 @@ async function reloadRecords(): Promise<void> {
pageResult.total = 0; pageResult.total = 0;
pageResult.list = []; pageResult.list = [];
pageResult.numberValueTotal = null; pageResult.numberValueTotal = null;
pageResult.customerCount = 0;
optionStatList.value = []; optionStatList.value = [];
isNoMore.value = false; isNoMore.value = false;
await Promise.all([loadRecords(), loadOptionStats()]); await Promise.all([loadRecords(), loadOptionStats()]);
@@ -420,6 +424,7 @@ async function loadRecords(): Promise<void> {
pageResult.list = pageResult.list.concat(result.list || []); pageResult.list = pageResult.list.concat(result.list || []);
pageResult.total = result.total || 0; pageResult.total = result.total || 0;
pageResult.numberValueTotal = result.numberValueTotal ?? null; pageResult.numberValueTotal = result.numberValueTotal ?? null;
pageResult.customerCount = result.customerCount ?? 0;
page.index += 1; page.index += 1;
isNoMore.value = pageResult.list.length >= pageResult.total || (result.list || []).length < page.size; isNoMore.value = pageResult.list.length >= pageResult.total || (result.list || []).length < page.size;
} finally { } finally {
@@ -471,7 +476,7 @@ async function selectDateFilter(type: DropdownValue): Promise<void> {
dateFilterType.value = String(type) as DateFilterType; dateFilterType.value = String(type) as DateFilterType;
if (dateFilterType.value !== "custom") { if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value); dateRange.value = buildPresetDateRange(dateFilterType.value);
await replaceFilterQuery(); await replaceFilterQuery();
await reloadRecords(); await reloadRecords();
} else { } else {
@@ -586,14 +591,9 @@ function getQueryText(key: string): string {
return Array.isArray(value) ? String(value[0] || "") : String(value || ""); 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] { function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") { if (type !== "custom") {
return buildPresetRange(type); return buildPresetDateRange(type);
} }
const beginAt = getQueryText("beginAt"); 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] { function buildUnixRange(): [number, number] {
if (dateFilterType.value === "all") { if (dateFilterType.value === "all") {
return [0, Time.toTimestamp(new Date())]; 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 () => { onMounted(async () => {
navBarStore.clearRight(); navBarStore.clearRight();
navBarStore.setCustomRightVisible(canShowActionMenu.value); navBarStore.setCustomRightVisible(canShowActionMenu.value);
+5 -51
View File
@@ -132,6 +132,7 @@ import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI"; import EventRecordAPI from "@/api/EventRecordAPI";
import type { CustomerEventRankView, EventRecordRankPage, EventRecordRankType } from "@/types/EventRecord"; import type { CustomerEventRankView, EventRecordRankPage, EventRecordRankType } from "@/types/EventRecord";
import type { EventItem } from "@/types/EventItem"; import type { EventItem } from "@/types/EventItem";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { formatEventNumberValue } from "@/utils/eventValue"; import { formatEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission"; 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 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 dateRange = ref(parseDateRange(dateFilterType.value));
const eventFilter = ref<string[]>([]); const eventFilter = ref<string[]>([]);
const eventList = ref<EventItem[]>([]); const eventList = ref<EventItem[]>([]);
@@ -341,9 +342,9 @@ async function selectDateFilter(value: DropdownValue): Promise<void> {
return; return;
} }
dateFilterType.value = parseDateFilterType(value); dateFilterType.value = parseDateFilterType(value, dateOptions, "all");
if (dateFilterType.value !== "custom") { if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value); dateRange.value = buildPresetDateRange(dateFilterType.value);
await replaceFilterQuery(); await replaceFilterQuery();
await reloadRanks(); await reloadRanks();
return; return;
@@ -388,14 +389,9 @@ function parseRankType(value: unknown): EventRecordRankType {
return rankTypeOptions.some(item => item.value === text) ? text as EventRecordRankType : "TOTAL_DAYS"; 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] { function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") { if (type !== "custom") {
return buildPresetRange(type); return buildPresetDateRange(type);
} }
return [getQueryText("beginAt"), getQueryText("endAt")]; 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] { function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") { if (dateFilterType.value === "all") {
return [undefined, undefined]; 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 () => { onMounted(async () => {
await loadEvents(); await loadEvents();
initialized = true; initialized = true;
+154 -34
View File
@@ -26,10 +26,7 @@
<div class="title"> <div class="title">
<div class="heading"> <div class="heading">
<div class="name-gift"> <div class="name-gift">
<strong <strong v-text="item.productNameSnapshot || '未命名产品'"/>
class="clip-text"
v-text="item.productNameSnapshot || '未命名产品'"
/>
<t-tag <t-tag
v-if="item.gifted" v-if="item.gifted"
size="small" size="small"
@@ -39,17 +36,12 @@
</div> </div>
<span class="can-fulfill" v-text="getItemDescription(item)" /> <span class="can-fulfill" v-text="getItemDescription(item)" />
</div> </div>
</div>
<div class="input">
<div <div
v-if="item.skuCodeSnapshot || item.skuSpecSnapshot?.length || item.skuNameSnapshot || item.specSummarySnapshot || item.unitSnapshot" v-if="item.skuCodeSnapshot || item.skuSpecSnapshot?.length || item.skuNameSnapshot || item.specSummarySnapshot || item.unitSnapshot"
class="sku-tags" class="sku-tags"
> >
<t-tag
v-if="item.skuCodeSnapshot"
size="small"
theme="primary"
variant="light"
:content="item.skuCodeSnapshot"
/>
<product-sku-snapshot-tags <product-sku-snapshot-tags
:product-name="item.productNameSnapshot" :product-name="item.productNameSnapshot"
:snapshot="item.skuSpecSnapshot" :snapshot="item.skuSpecSnapshot"
@@ -57,8 +49,6 @@
:unit="item.unitSnapshot" :unit="item.unitSnapshot"
/> />
</div> </div>
</div>
<div class="input">
<t-input <t-input
v-model.trim="quantityMap[item.id || '']" v-model.trim="quantityMap[item.id || '']"
class="quantity" class="quantity"
@@ -80,7 +70,7 @@
<t-button <t-button
size="extra-small" size="extra-small"
variant="outline" variant="outline"
:disabled="getAvailableBaseQuantity(item) === '0'" :disabled="getAvailableBaseQuantity(item) === '0' || (directVerify && isStockChecking)"
@click.stop="fillAllQuantity(item)" @click.stop="fillAllQuantity(item)"
>全部履约</t-button> >全部履约</t-button>
</div> </div>
@@ -89,7 +79,7 @@
</t-form-item> </t-form-item>
</t-form> </t-form>
<div v-else class="empty" v-text="'暂无可履约明细,请返回订单详情检查'" /> <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-form-item label="履约时间" content-align="right">
<t-button <t-button
theme="primary" theme="primary"
@@ -99,6 +89,15 @@
@click="pickerVisible = true" @click="pickerVisible = true"
/> />
</t-form-item> </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-form-item label="备注">
<t-textarea v-model="remark" borderless autosize maxlength="500" /> <t-textarea v-model="remark" borderless autosize maxlength="500" />
</t-form-item> </t-form-item>
@@ -129,17 +128,31 @@ import FulfillmentAPI from "@/api/FulfillmentAPI";
import ProductAPI from "@/api/ProductAPI"; import ProductAPI from "@/api/ProductAPI";
import PhotoScroller from "@/components/PhotoScroller.vue"; import PhotoScroller from "@/components/PhotoScroller.vue";
import ProductSkuSnapshotTags from "@/components/ProductSkuSnapshotTags.vue"; import ProductSkuSnapshotTags from "@/components/ProductSkuSnapshotTags.vue";
import ProductStockPreview from "@/components/ProductStockPreview.vue";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { Attachment } from "timi-web-api";
import { useUserStore } from "timi-web-api"; import { useUserStore } from "timi-web-api";
import type { GaoOrderFulfillmentItemRequest } from "@/types/Fulfillment"; import type { GaoOrderFulfillmentItemRequest } from "@/types/Fulfillment";
import type { GaoSalesOrderItem, SalesOrder } from "@/types/SalesOrder"; import type { GaoSalesOrderItem, SalesOrder } from "@/types/SalesOrder";
import type { ProductSku, ProductSkuUnit } from "@/types/Product"; import type { ProductSku, ProductSkuUnit } from "@/types/Product";
import type { PhotoItem } from "@/types/Photo"; import type { PhotoItem } from "@/types/Photo";
import type { Attachment } from "timi-web-api"; import {
import { comparePackageQuantity, convertPackageQuantity, createPackageRequestNo, getPackageQuantityText, normalizePackageQuantity, toPackageBaseQuantity } from "@/utils/package"; comparePackageQuantity,
convertPackageQuantity,
createPackageRequestNo,
getPackageQuantityText,
normalizePackageQuantity,
toPackageBaseQuantity
} from "@/utils/package";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission"; 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 { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL } from "@/utils/product"; import { getProductPhotoURL } from "@/utils/product";
@@ -162,6 +175,10 @@ const photoItems = ref<PhotoItem[]>([]);
const unitPickerVisible = ref(false); const unitPickerVisible = ref(false);
const unitPickerSourceItemId = ref(""); const unitPickerSourceItemId = ref("");
const unitPickerDraft = ref<Array<string | number>>([]); 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 photoUrlList = computed(() => photoItems.value.map(item => item.url));
const isPackage = computed(() => order.value?.orderType === "PACKAGE"); const isPackage = computed(() => order.value?.orderType === "PACKAGE");
const expectedItemType = computed(() => isPackage.value ? "PACKAGE_ENTITLEMENT" : "DIRECT_PRODUCT"); 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 || "" })) : []; return item ? getFulfillmentUnitList(item).map(unit => ({ label: unit.name, value: unit.id || "" })) : [];
}); });
const canFulfillByPayment = computed(() => order.value?.paymentStatus === "PAID" || userStore.hasRoles(gaoRoleCodes.storeManager)); 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> { async function loadData(): Promise<void> {
const salesOrderId = String(route.query.salesOrderId || ""); const salesOrderId = String(route.query.salesOrderId || "");
@@ -250,7 +290,7 @@ function getFulfillmentUnitForAvailableQuantity(item: GaoSalesOrderItem, unit?:
return getBaseUnit(item) || unit; return getBaseUnit(item) || unit;
} }
function fillAllQuantity(item: GaoSalesOrderItem): void { async function fillAllQuantity(item: GaoSalesOrderItem): Promise<void> {
const unit = getFulfillmentUnit(item); const unit = getFulfillmentUnit(item);
if (!item.id || !unit) { if (!item.id || !unit) {
Message.warning("请先选择实际履约单位"); Message.warning("请先选择实际履约单位");
@@ -262,6 +302,9 @@ function fillAllQuantity(item: GaoSalesOrderItem): void {
return; return;
} }
quantityMap[item.id] = quantity; quantityMap[item.id] = quantity;
if (directVerify.value) {
await previewDirectStock();
}
} }
function getFulfillmentUnitList(item: GaoSalesOrderItem): ProductSkuUnit[] { 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 { function validate(): boolean {
if (!order.value?.id) { if (!order.value?.id) {
Message.warning("请从订单详情进入履约创建"); Message.warning("请从订单详情进入履约创建");
@@ -386,7 +499,12 @@ function validate(): boolean {
} }
async function save(): Promise<void> { 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(); if (!requestNo.value) requestNo.value = createPackageRequestNo();
isSaving.value = true; isSaving.value = true;
try { try {
@@ -397,15 +515,19 @@ async function save(): Promise<void> {
skuUnitId: fulfillmentUnitMap[item.id!]!, skuUnitId: fulfillmentUnitMap[item.id!]!,
quantity: normalizePackageQuantity(quantityMap[item.id || ""])! quantity: normalizePackageQuantity(quantityMap[item.id || ""])!
})); }));
const result = await FulfillmentAPI.create({ const request = {
salesOrderId: order.value!.id!, salesOrderId: order.value!.id!,
requestNo: requestNo.value, requestNo: requestNo.value,
deliveredAt: resolveTimestamp(deliveredAtText.value), deliveredAt: resolveTimestamp(deliveredAtText.value),
itemList, itemList,
attachmentList: buildAttachments(), attachmentList: buildAttachments(),
remark: remark.value.trim() || null 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}`); await router.replace(`/management/order-fulfillments/${result.id}`);
} catch (error) { } catch (error) {
Message.error(getErrorMessage(error, "履约单创建失败,重试会复用同一请求号")); Message.error(getErrorMessage(error, "履约单创建失败,重试会复用同一请求号"));
@@ -487,10 +609,10 @@ onUnmounted(() => navBarStore.clearRight());
min-width: 0; min-width: 0;
display: flex; display: flex;
overflow: hidden; overflow: hidden;
align-items: center;
justify-content: start;
strong { strong {
flex: 1 1 0;
min-width: 0;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -503,6 +625,12 @@ onUnmounted(() => navBarStore.clearRight());
white-space: nowrap; white-space: nowrap;
} }
} }
}
.input {
gap: .5rem;
display: flex;
margin-top: .5rem;
.sku-tags { .sku-tags {
gap: .375rem; gap: .375rem;
@@ -510,12 +638,6 @@ onUnmounted(() => navBarStore.clearRight());
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
} }
}
.input {
gap: .5rem;
display: flex;
margin-top: .5rem;
.unit { .unit {
font-size: .875rem; font-size: .875rem;
@@ -526,10 +648,8 @@ onUnmounted(() => navBarStore.clearRight());
} }
} }
.empty { .tips {
color: var(--td-text-color-secondary); font-size: .875rem;
padding: 2rem 1rem;
text-align: center;
} }
} }
@@ -62,7 +62,7 @@
<t-cell v-if="!fulfillment.itemList?.length" title="暂无履约明细" /> <t-cell v-if="!fulfillment.itemList?.length" title="暂无履约明细" />
</t-cell-group> </t-cell-group>
<section <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" class="actions"
v-entrance-transition="140" v-entrance-transition="140"
> >
@@ -77,7 +77,7 @@
<t-button theme="danger" content="拒绝履约" @click="openReason('reject')" /> <t-button theme="danger" content="拒绝履约" @click="openReason('reject')" />
</div> </div>
<t-button <t-button
v-if="!isOrderCancelled && isPending && canCreate" v-if="!isOrderCancelled && (isWaitingStock || isPending) && canCreate"
theme="default" theme="default"
content="撤销履约" content="撤销履约"
@click="openReason('cancel')" @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 { NavBarAction } from "@/types/NavBarAction";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { SalesOrder } from "@/types/SalesOrder"; import type { SalesOrder } from "@/types/SalesOrder";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
@@ -186,7 +187,7 @@ const selectedCustomer = ref<Customer>();
const salesOrder = ref<SalesOrder>(); const salesOrder = ref<SalesOrder>();
const customerSelectorVisible = ref(false); const customerSelectorVisible = ref(false);
const customerSearchKeyword = ref(""); 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 dateRange = ref<[string, string]>(parseDateRange(dateFilterType.value));
const startDraft = ref(""); const startDraft = ref("");
const endDraft = ref(""); const endDraft = ref("");
@@ -224,14 +225,9 @@ function getInitialStatus(value: unknown): "ALL" | GaoFulfillmentStatus {
return "ALL"; 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] { function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") { if (type !== "custom") {
return buildPresetRange(type); return buildPresetDateRange(type);
} }
return [getQueryText(route.query.beginAt), getQueryText(route.query.endAt)]; 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; dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") { if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value); dateRange.value = buildPresetDateRange(dateFilterType.value);
await replaceFilterQuery(); await replaceFilterQuery();
await reloadList(); await reloadList();
} else { } else {
@@ -444,45 +440,6 @@ function isValidTransitionDepth(value: string): boolean {
return value !== "" && Number.isInteger(depth) && 0 <= depth && depth <= 20; 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] { function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") { if (dateFilterType.value === "all") {
return [0, Time.toTimestamp(new Date())]; 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> { async function toDetail(item: OrderFulfillment): Promise<void> {
if (item.id) { if (item.id) {
await router.push({ await router.push({
+27 -19
View File
@@ -50,6 +50,7 @@
placeholder="请选择产品" placeholder="请选择产品"
required-mark required-mark
@update:model-value="applyProduct(index, $event)" @update:model-value="applyProduct(index, $event)"
@select="applyProductSelection(index, $event)"
/> />
<t-form-item v-if="item.skuId" label="SKU 单位" content-align="right"> <t-form-item v-if="item.skuId" label="SKU 单位" content-align="right">
<span v-text="getUnit(item)?.name || '未选择单位'" /> <span v-text="getUnit(item)?.name || '未选择单位'" />
@@ -111,7 +112,7 @@ import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package"; import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
import ProductAPI from "@/api/ProductAPI"; 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 { getErrorMessage } from "@/utils/error";
import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package"; import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
@@ -204,23 +205,27 @@ const estimatedOriginalAmount = computed<number | undefined>(() => {
}); });
const estimatedOriginalAmountText = computed(() => estimatedOriginalAmount.value === undefined ? "待完善" : `¥${formatProductAmount(estimatedOriginalAmount.value)}`); 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) { if (!value) {
return; 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], { Object.assign(form.itemList[index], {
skuId: value.skuId, skuId: value.skuId,
skuUnitId: value.skuUnitId, skuUnitId: value.skuUnitId
productNameSnapshot: product.name, });
skuNameSnapshot: sku.name, }
specSummarySnapshot: sku.specSummary,
unitSnapshot: unit?.name || "", function applyProductSelection(index: number, selection: ProductSelection): void {
baseQuantityPerUnitSnapshot: unit?.baseQuantityPerUnit 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 result = await PackageAPI.configDetail(routeId.value);
const itemList = (result.itemList || []).map(createItem); const itemList = (result.itemList || []).map(createItem);
const skuIdList = [...new Set(itemList.map(item => item.skuId).filter((id): id is string => !!id))]; for (const item of itemList) {
await Promise.all(skuIdList.map(async skuId => { if (item.skuId && item.sku) {
const sku = itemList.find(item => item.skuId === skuId)?.sku || await ProductAPI.skuDetail(skuId); skuCache.set(item.skuId, item.sku);
skuCache.set(skuId, sku);
if (sku.productId) {
productCache.set(sku.productId, await ProductAPI.detail(sku.productId));
} }
}
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, { Object.assign(form, {
id: result.id || routeId.value, id: result.id || routeId.value,
+106 -34
View File
@@ -199,11 +199,14 @@
<t-switch <t-switch
v-model="fulfillAll" v-model="fulfillAll"
size="small" size="small"
:disabled="!canFulfillImmediately" :disabled="!canFulfillImmediately || isStockChecking"
/> />
</t-form-item> </t-form-item>
<t-form-item label-width="0"> <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>
<t-form-item v-if="(paymentVerified && parsedPaymentAmount) || (receiveFullPayment && parsedSaleAmount)" label-width="0"> <t-form-item v-if="(paymentVerified && parsedPaymentAmount) || (receiveFullPayment && parsedSaleAmount)" label-width="0">
<photo-scroller <photo-scroller
@@ -337,6 +340,7 @@ import CustomerList from "@/components/CustomerList.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue"; import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue"; import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoScroller from "@/components/PhotoScroller.vue"; import PhotoScroller from "@/components/PhotoScroller.vue";
import ProductStockPreview from "@/components/ProductStockPreview.vue";
import { usePackagePurchase } from "@/pages/package/usePackagePurchase"; import { usePackagePurchase } from "@/pages/package/usePackagePurchase";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import { useSettingStore } from "@/store/settingStore"; import { useSettingStore } from "@/store/settingStore";
@@ -351,10 +355,13 @@ import {
formatPackageAmount, formatPackageAmount,
getPackageQuantityText, getPackageQuantityText,
normalizePackageQuantity, normalizePackageQuantity,
parsePackageAmount parsePackageAmount,
toPackageBaseQuantity
} from "@/utils/package"; } from "@/utils/package";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
import { buildProductStockPreview, getProductStockShortageMessage, mergeProductStockRequirements } from "@/utils/productStock";
import type { ProductStockPreviewItem } from "@/utils/productStock";
import { uploadPhotoList } from "@/utils/photo"; import { uploadPhotoList } from "@/utils/photo";
import { getProductPhotoURL, getProductSkuSnapshotText } from "@/utils/product"; 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 fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
const receiveFullPayment = ref(false); const receiveFullPayment = ref(false);
const fulfillAll = ref(false); const fulfillAll = ref(false);
const isStockChecking = ref(false);
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
let stockCheckToken = 0;
const parsedPaymentAmount = computed(() => parsePackageAmount(paymentAmountText.value)); const parsedPaymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
const paymentVerified = computed(() => !receiveFullPayment.value const paymentVerified = computed(() => !receiveFullPayment.value
&& 0 < paymentAmountText.value.trim().length && 0 < paymentAmountText.value.trim().length
@@ -553,12 +563,30 @@ const canReceiveImmediately = computed(() => userStore.hasPermissions(permission
&& userStore.hasPermissions(permissionCodes.fundReceive.verify)); && userStore.hasPermissions(permissionCodes.fundReceive.verify));
const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create) const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify) && userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
&& userStore.hasPermissions(permissionCodes.productStock.read)
&& (parsedSaleAmount.value === 0 || canReceiveImmediately.value)); && (parsedSaleAmount.value === 0 || canReceiveImmediately.value));
watch(fulfillAll, value => { watch(fulfillAll, async value => {
if (value && parsedSaleAmount.value !== 0) { 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; receiveFullPayment.value = true;
} }
} finally {
if (token === stockCheckToken) {
isStockChecking.value = false;
}
}
}); });
watch(receiveFullPayment, value => { watch(receiveFullPayment, value => {
@@ -603,6 +631,14 @@ function getItemSku(item: PackagePurchaseItem): ProductSku | undefined {
return skuMap[item.skuId] || config.value?.itemList?.find(configItem => configItem.skuId === item.skuId)?.sku; 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 { function getItemUnit(item: PackagePurchaseItem): ProductSkuUnit | undefined {
return getItemSku(item)?.unitList?.find(unit => unit.id === item.skuUnitId); return getItemSku(item)?.unitList?.find(unit => unit.id === item.skuUnitId);
} }
@@ -762,10 +798,70 @@ function validateItemDecimalQuantity(): boolean {
return true; 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> { async function submitRecord(): Promise<void> {
if (isSaving.value) { if (isSaving.value) {
return; return;
} }
if (isStockChecking.value) {
Message.warning("库存正在预检,请稍候再提交");
return;
}
if (!validatePurchaseForm() || !validateItemDecimalQuantity()) { if (!validatePurchaseForm() || !validateItemDecimalQuantity()) {
return; return;
} }
@@ -796,6 +892,7 @@ async function purchasePackage(): Promise<void> {
isSaving.value = true; isSaving.value = true;
try { try {
if (fulfillAll.value && !await validateDirectStock()) return;
const result = await submitPurchase(); const result = await submitPurchase();
if (!result?.salesOrderId) { if (!result?.salesOrderId) {
Message.error("套餐购买成功但未返回完整订单关联,请联系管理员"); Message.error("套餐购买成功但未返回完整订单关联,请联系管理员");
@@ -823,8 +920,8 @@ async function loadPageData(): Promise<void> {
return; return;
} }
config.value = await PackageAPI.configDetail(configId.value); config.value = await PackageAPI.configDetail(configId.value);
syncConfigSkuMap(config.value);
applyConfig(config.value); applyConfig(config.value);
await loadConfigSkuList(config.value);
} }
async function selectConfig(item: GaoPackageConfig): Promise<void> { async function selectConfig(item: GaoPackageConfig): Promise<void> {
@@ -832,21 +929,11 @@ async function selectConfig(item: GaoPackageConfig): Promise<void> {
return; return;
} }
config.value = await PackageAPI.configDetail(item.id); config.value = await PackageAPI.configDetail(item.id);
await loadConfigSkuList(config.value); syncConfigSkuMap(config.value);
applyConfig(config.value);
configSelectorVisible.value = false; 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 () => { onMounted(async () => {
navBarStore.clearRight(); navBarStore.clearRight();
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) { if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
@@ -895,23 +982,8 @@ watch(config, value => {
width: 100%; 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 { .tips {
color: var(--td-text-color-secondary); font-size: .875rem;
margin: .5rem .5rem 0;
font-size: .75rem;
line-height: 1.5;
}
} }
.customer-profile { .customer-profile {
+8 -32
View File
@@ -142,7 +142,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownValue } from "tdesign-mobile-vue"; 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 { type PageResult, useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
import PointAPI from "@/api/PointAPI"; import PointAPI from "@/api/PointAPI";
@@ -151,6 +151,7 @@ import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { PointLedger, PointLedgerChangeType, PointLedgerPage } from "@/types/Point"; import type { PointLedger, PointLedgerChangeType, PointLedgerPage } from "@/types/Point";
import { getCustomerPhotoURL } from "@/utils/customer"; import { getCustomerPhotoURL } from "@/utils/customer";
import { buildPresetDateRange, parseDateTime } from "@/utils/date";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
@@ -175,7 +176,7 @@ const customerKeyword = ref("");
const selectorVisible = ref(false); const selectorVisible = ref(false);
const changeTypeFilter = ref("ALL"); const changeTypeFilter = ref("ALL");
const dateFilter = ref<DateFilter>("last30"); const dateFilter = ref<DateFilter>("last30");
const dateRange = ref<[string, string]>(buildDateRange(dateFilter.value)); const dateRange = ref<[string, string]>(buildPresetDateRange("last30"));
const startDraft = ref(""); const startDraft = ref("");
const endDraft = ref(""); const endDraft = ref("");
const startDateVisible = ref(false); const startDateVisible = ref(false);
@@ -253,7 +254,7 @@ async function selectDateFilter(value: DropdownValue): Promise<void> {
} }
dateFilter.value = String(value) as DateFilter; dateFilter.value = String(value) as DateFilter;
if (dateFilter.value !== "custom") { if (dateFilter.value !== "custom") {
dateRange.value = buildDateRange(dateFilter.value); dateRange.value = buildPresetDateRange(dateFilter.value);
await reload(); 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] { function buildUnixRange(): [number, number] {
if (!dateRange.value[0] && !dateRange.value[1]) { if (!dateRange.value[0] && !dateRange.value[1]) {
return [0, Date.now()]; return [0, Date.now()];
} }
return [toTimestamp(dateRange.value[0], false), toTimestamp(dateRange.value[1], true)]; return [
} parseDateTime(dateRange.value[0], false).getTime(),
parseDateTime(dateRange.value[1], true).getTime()
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();
} }
// ---------- 页面数据 ---------- // ---------- 页面数据 ----------
+113 -8
View File
@@ -1,14 +1,40 @@
<template> <template>
<div v-can="permissionCodes.pointRedeemSku.manage" class="redeem-sku-list"> <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-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-group v-if="result.list.length" theme="card">
<t-cell <t-cell
v-for="(item, index) in result.list" v-for="(item, index) in result.list"
:key="item.id" :key="item.id"
class="item"
arrow arrow
:description="item.unit?.name || '单位未知'"
v-entrance-transition="index * 30" v-entrance-transition="index * 30"
@click="toEdit(item)" @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> <template #title>
<div class="title"> <div class="title">
<strong v-text="item.product?.name || item.sku?.name || '未命名产品'" /> <strong v-text="item.product?.name || item.sku?.name || '未命名产品'" />
@@ -19,10 +45,8 @@
/> />
</div> </div>
</template> </template>
<template #description> <template #note>
<span <span class="orange" v-text="formatPoint(item.defaultPointCost)" />
v-text="`${item.sku?.code || item.sku?.specSummary || '默认 SKU'} · ${item.unit?.name || '单位未知'} · ${formatPoint(item.defaultPointCost)} / 单位`"
/>
</template> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
@@ -37,22 +61,31 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Toolkit } from "timi-web";
import PointAPI from "@/api/PointAPI"; import PointAPI from "@/api/PointAPI";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { PointRedeemSku, PointRedeemSkuStatus } from "@/types/Point"; import type { PointRedeemSku, PointRedeemSkuStatus } from "@/types/Point";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { getProductPhotoURL } from "@/utils/product";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "PointRedeemSkuList" }); defineOptions({ name: "PointRedeemSkuList" });
const router = useRouter(); const router = useRouter();
const navBarStore = useNavBarStore(); const navBarStore = useNavBarStore();
const result = reactive({ total: 0, list: [] as PointRedeemSku[] }); const result = reactive({ total: 0, list: [] as PointRedeemSku[] });
const keyword = shallowRef("");
const index = ref(0); const index = ref(0);
const isLoading = ref(false); const isLoading = ref(false);
const isRefreshing = ref(false); const isRefreshing = ref(false);
const isNoMore = ref(false); const isNoMore = ref(false);
const pageSize = 20;
let reloadRequested = false;
async function reload(): Promise<void> { async function reload(): Promise<void> {
if (isLoading.value) {
reloadRequested = true;
return;
}
index.value = 0; index.value = 0;
result.total = 0; result.total = 0;
result.list = []; result.list = [];
@@ -70,23 +103,40 @@ async function load(): Promise<void> {
} }
isLoading.value = true; isLoading.value = true;
try { 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 || []; const list = page.list || [];
result.list = result.list.concat(list); result.list = result.list.concat(list);
result.total = page.total || 0; result.total = page.total || 0;
index.value++; index.value++;
isNoMore.value = result.total <= result.list.length || list.length < 20; isNoMore.value = result.total <= result.list.length || list.length < pageSize;
} catch { } catch {
Message.error("兑换配置加载失败"); Message.error("兑换配置加载失败");
} finally { } finally {
isLoading.value = false; isLoading.value = false;
if (reloadRequested) {
reloadRequested = false;
await reload();
}
} }
} }
const reloadByKeyword = Toolkit.debounce(async () => {
await reload();
}, false);
function formatPoint(value?: number): string { function formatPoint(value?: number): string {
return `${(value || 0).toLocaleString("zh-CN")} 分`; 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 { function statusText(status?: PointRedeemSkuStatus): string {
return ({ return ({
DRAFT: "草稿", DRAFT: "草稿",
@@ -111,12 +161,54 @@ onMounted(async () => {
await reload(); await reload();
}); });
onUnmounted(() => navBarStore.clearRight()); watch(keyword, () => {
reloadByKeyword();
});
onUnmounted(() => {
reloadByKeyword.cancel();
navBarStore.clearRight();
});
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.redeem-sku-list { .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 { .title {
gap: .5rem; gap: .5rem;
@@ -131,4 +223,17 @@ onUnmounted(() => navBarStore.clearRight());
text-align: center; text-align: center;
} }
} }
:global(html.theme-dark) {
.redeem-sku-list {
.filters {
background: #10161f;
}
.fallback {
color: #ccfbf1;
background: #115e59;
}
}
}
</style> </style>
+10 -5
View File
@@ -6,7 +6,7 @@
:customer="customer" :customer="customer"
v-entrance-transition 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.customer?.name || item.customerId || '-'" />
<t-cell title="兑换商品" :note="item.productNameSnapshot || item.skuNameSnapshot || '-'" /> <t-cell title="兑换商品" :note="item.productNameSnapshot || item.skuNameSnapshot || '-'" />
<t-cell title="兑换数量" :note="`${quantityText(item.quantity)} ${item.unitSnapshot || ''}`" /> <t-cell title="兑换数量" :note="`${quantityText(item.quantity)} ${item.unitSnapshot || ''}`" />
@@ -24,7 +24,7 @@
/> />
</div> </div>
</t-cell-group> </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 <t-cell
v-for="record in returnList" v-for="record in returnList"
:key="record.id" :key="record.id"
@@ -33,9 +33,14 @@
/> />
</t-cell-group> </t-cell-group>
<t-empty v-if="!item" :description="loading ? '正在加载兑换单' : '兑换单不存在'" /> <t-empty v-if="!item" :description="loading ? '正在加载兑换单' : '兑换单不存在'" />
<t-button v-if="canReturn" class="return-button cell-button" theme="danger" @click="returnVisible = true"> <t-button
冲正兑换 v-if="canReturn"
</t-button> class="return-button cell-button"
theme="danger"
content="冲正兑换"
v-entrance-transition="140"
@click="returnVisible = true"
/>
</div> </div>
<bottom-popup <bottom-popup
v-model="returnVisible" v-model="returnVisible"
+24 -89
View File
@@ -47,7 +47,8 @@
@scrolltolower="load" @scrolltolower="load"
> >
<div class="list-wrap"> <div class="list-wrap">
<t-cell-group v-if="isCustomDate" class="date-summary" theme="card"> <t-cell-group class="date-summary" theme="card" v-entrance-transition>
<template v-if="isCustomDate">
<t-cell <t-cell
title="开始时间" title="开始时间"
:note="dateRange[0] || '-'" :note="dateRange[0] || '-'"
@@ -60,6 +61,8 @@
arrow arrow
@click="openEndTime" @click="openEndTime"
/> />
</template>
<t-cell title="总数据量" :note="Text.display(result.total)" />
</t-cell-group> </t-cell-group>
<div v-if="result.list.length" class="items"> <div v-if="result.list.length" class="items">
<t-cell-group theme="card"> <t-cell-group theme="card">
@@ -68,7 +71,7 @@
:key="item.id" :key="item.id"
class="item" class="item"
arrow arrow
v-entrance-transition="index * 30" v-entrance-transition="70 + index * 30"
@click="toDetail(item)" @click="toDetail(item)"
> >
<template #left-icon> <template #left-icon>
@@ -95,8 +98,11 @@
/> />
</div> </div>
</template> </template>
<template #note>
<span class="orange" v-text="formatPoint(item.actualPointCost)" />
</template>
<template #description> <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> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
@@ -105,7 +111,6 @@
v-else v-else
:description="loading ? '正在加载兑换记录' : hasFilter ? '未找到匹配的兑换记录' : '暂无兑换记录'" :description="loading ? '正在加载兑换记录' : hasFilter ? '未找到匹配的兑换记录' : '暂无兑换记录'"
/> />
<div class="tips" v-text="`共 ${result.total} 条兑换记录`" />
</div> </div>
</t-pull-down-refresh> </t-pull-down-refresh>
</div> </div>
@@ -146,13 +151,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue"; 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 CustomerAPI from "@/api/CustomerAPI";
import PointAPI from "@/api/PointAPI"; import PointAPI from "@/api/PointAPI";
import CustomerSelector from "@/components/CustomerSelector.vue"; import CustomerSelector from "@/components/CustomerSelector.vue";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { PointRedemption, PointRedemptionStatus } from "@/types/Point"; import type { PointRedemption, PointRedemptionStatus } from "@/types/Point";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { getPackageQuantityText } from "@/utils/package"; import { getPackageQuantityText } from "@/utils/package";
import { getCustomerPhotoURL } from "@/utils/customer"; import { getCustomerPhotoURL } from "@/utils/customer";
@@ -178,7 +184,7 @@ const keyword = ref(getQueryText(route.query.keyword));
const customerId = ref(getQueryText(route.query.customerId)); const customerId = ref(getQueryText(route.query.customerId));
const selectedCustomer = ref<Customer>(); const selectedCustomer = ref<Customer>();
const customerSelectorVisible = ref(false); 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 dateRange = ref<[string, string]>(parseDateRange(dateFilterType.value));
const startDraft = ref(""); const startDraft = ref("");
const endDraft = ref(""); const endDraft = ref("");
@@ -188,7 +194,6 @@ const index = ref(0);
const loading = ref(false); const loading = ref(false);
const refreshing = ref(false); const refreshing = ref(false);
const noMore = ref(false); const noMore = ref(false);
let keywordTimer: number | undefined;
let reloadRequested = false; let reloadRequested = false;
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "时间范围"); const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "时间范围");
const isCustomDate = computed(() => dateFilterType.value === "custom"); const isCustomDate = computed(() => dateFilterType.value === "custom");
@@ -198,14 +203,9 @@ function getQueryText(value: unknown): string {
return Array.isArray(value) ? "" : typeof value === "string" ? value : ""; 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] { function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") { if (type !== "custom") {
return buildPresetRange(type); return buildPresetDateRange(type);
} }
return [getQueryText(route.query.beginAt), getQueryText(route.query.endAt)]; 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; dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") { if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value); dateRange.value = buildPresetDateRange(dateFilterType.value);
await replaceFilterQuery(); await replaceFilterQuery();
await reload(); await reload();
} else { } 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] { function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") { if (dateFilterType.value === "all") {
return [0, Time.toTimestamp(new Date())]; 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 { function statusText(value?: PointRedemptionStatus): string {
return ({ return ({
COMPLETED: "已兑换", COMPLETED: "已兑换",
@@ -443,15 +394,16 @@ onMounted(async () => {
} }
}); });
watch(keyword, () => { const reloadByKeyword = Toolkit.debounce(async () => {
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
await reload(); await reload();
}, 300); }, false);
watch(keyword, () => {
reloadByKeyword();
}); });
onUnmounted(() => { onUnmounted(() => {
window.clearTimeout(keywordTimer); reloadByKeyword.cancel();
navBarStore.clearRight(); navBarStore.clearRight();
}); });
</script> </script>
@@ -506,22 +458,11 @@ onUnmounted(() => {
} }
.list-wrap { .list-wrap {
gap: 1rem;
display: flex;
min-height: 70vh;
padding-top: 1rem; padding-top: 1rem;
flex-direction: column;
.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;
}
}
.items { .items {
gap: 1rem; gap: 1rem;
@@ -546,12 +487,6 @@ onUnmounted(() => {
} }
} }
} }
.tips {
color: var(--td-text-color-secondary);
padding: 1rem;
text-align: center;
}
} }
} }
+51 -6
View File
@@ -203,7 +203,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile"; 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 { type Attachment, AttachmentAPI, useUserStore } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI"; import ProductAPI from "@/api/ProductAPI";
import PhotoScroller from "@/components/PhotoScroller.vue"; 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 ProductSkuUnitBatchEditor from "@/pages/product/component/ProductSkuUnitBatchEditor.vue";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { PhotoItem } from "@/types/Photo"; 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 type { ProductAttachmentItem, ProductForm, ProductSkuForm as SkuForm, ProductSkuUnitForm as UnitForm, ProductSpecForm as SpecForm, ProductSpecValueForm as SpecValueForm } from "@/types/ProductEditor";
import { compressUploadImage } from "@/utils/imageCompression"; import { compressUploadImage } from "@/utils/imageCompression";
import { getAttachmentReadURL } from "@/utils/attachment"; import { getAttachmentReadURL } from "@/utils/attachment";
@@ -464,19 +464,64 @@ function openTypePicker(): void {
typePickerVisible.value = true; typePickerVisible.value = true;
} }
function confirmType(value: Array<string | number>): void { async function confirmType(value: Array<string | number>): Promise<void> {
const type = String(value[0] || ""); const type = String(value[0] || "");
if (type === "SERVICE" || type === "GOODS" || type === "CONSUMABLE") { 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; form.productType = type;
if (type === "SERVICE") { if (type !== "SERVICE") {
return;
}
form.stockEnabled = false; form.stockEnabled = false;
form.skuList.forEach(sku => { form.skuList.forEach(sku => {
sku.stockEnabled = false; sku.stockEnabled = false;
sku.minStockQuantityText = "";
sku.unitList.forEach(unit => { unit.allowPurchase = false; }); 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;
} }
typePickerVisible.value = false;
} }
function openCategoryPicker(): void { function openCategoryPicker(): void {
+69 -8
View File
@@ -9,6 +9,13 @@
</template> </template>
</product-profile> </product-profile>
<t-cell-group theme="card" title="库存" v-entrance-transition="70"> <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"> <t-cell class="empty-cell-title-margin">
<template #title> <template #title>
<span></span> <span></span>
@@ -18,20 +25,20 @@
<div class="main"> <div class="main">
<span class="label">库存</span> <span class="label">库存</span>
<div class="value"> <div class="value">
<strong class="text" v-text="getProductQuantityText(stock.onHandQuantity)" /> <strong class="text" v-text="displayStock.onHandQuantity" />
<span class="unit" v-text="getProductBaseUnitName(sku) || '基础单位'" /> <span class="unit" v-text="selectedUnitName" />
</div> </div>
</div> </div>
<div class="footer"> <div class="footer">
<div class="available"> <div class="available">
<span class="label">可用</span> <span class="label">可用</span>
<strong class="value" v-text="getProductQuantityText(stock.availableQuantity)" /> <strong class="value" v-text="displayStock.availableQuantity" />
<span class="unit" v-text="getProductBaseUnitName(sku) || '基础单位'" /> <span class="unit" v-text="selectedUnitName" />
</div> </div>
<div class="reserved"> <div class="reserved">
<span class="label">预占</span> <span class="label">预占</span>
<strong class="value" v-text="getProductQuantityText(stock.reservedQuantity)" /> <strong class="value" v-text="displayStock.reservedQuantity" />
<span class="unit" v-text="getProductBaseUnitName(sku) || '基础单位'" /> <span class="unit" v-text="selectedUnitName" />
</div> </div>
</div> </div>
</div> </div>
@@ -74,22 +81,33 @@
:operation="operationType" :operation="operationType"
@success="loadAll" @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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { shallowRef } from "vue";
import { useUserStore } from "timi-web-api"; import { useUserStore } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI"; import ProductAPI from "@/api/ProductAPI";
import ProductProfile from "@/pages/product/component/ProductProfile.vue"; import ProductProfile from "@/pages/product/component/ProductProfile.vue";
import ProductStockOperationPopup from "@/pages/product/ProductStockOperationPopup.vue"; import ProductStockOperationPopup from "@/pages/product/ProductStockOperationPopup.vue";
import { useNavBarStore } from "@/store/navBarStore"; 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 { getErrorMessage } from "@/utils/error";
import { getProductBaseUnitName, getProductQuantityText } from "@/utils/product"; import { convertBaseQuantityToPackageQuantity } from "@/utils/package";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
type ManualStockOperation = Extract<StockOperationType, "MANUAL_IN" | "MANUAL_OUT" | "STOCKTAKE_GAIN" | "STOCKTAKE_LOSS">; type ManualStockOperation = Extract<StockOperationType, "MANUAL_IN" | "MANUAL_OUT" | "STOCKTAKE_GAIN" | "STOCKTAKE_LOSS">;
type PickerValue = string | number;
defineOptions({ name: "ProductStockDetail" }); defineOptions({ name: "ProductStockDetail" });
@@ -103,7 +121,21 @@ const stock = ref<ProductStock>();
const isLoading = ref(false); const isLoading = ref(false);
const operationVisible = ref(false); const operationVisible = ref(false);
const operationType = ref<ManualStockOperation>("MANUAL_IN"); 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 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" }> = [ const operationList: Array<{ text: string; icon: string; type: ManualStockOperation; theme: "primary" | "danger" }> = [
{ text: "手工入库", icon: "import", type: "MANUAL_IN", theme: "primary" }, { text: "手工入库", icon: "import", type: "MANUAL_IN", theme: "primary" },
{ text: "手工出库", icon: "export", type: "MANUAL_OUT", theme: "danger" }, { 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"; 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> { async function loadAll(): Promise<void> {
const skuId = String(route.params.skuId || ""); const skuId = String(route.params.skuId || "");
if (!skuId) { if (!skuId) {
@@ -143,6 +203,7 @@ async function loadAll(): Promise<void> {
]); ]);
product.value = productResult; product.value = productResult;
sku.value = skuResult; sku.value = skuResult;
setDefaultUnit(skuResult);
stock.value = { ...stockResult, sku: skuResult }; stock.value = { ...stockResult, sku: skuResult };
} finally { } finally {
isLoading.value = false; isLoading.value = false;
+9 -2
View File
@@ -29,7 +29,14 @@
@click="toSkuList(item)" @click="toSkuList(item)"
> >
<template #leftIcon> <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>
<template #title> <template #title>
<div class="title"> <div class="title">
@@ -67,7 +74,7 @@ import ProductAPI from "@/api/ProductAPI";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { ProductStockProduct } from "@/types/Product"; import type { ProductStockProduct } from "@/types/Product";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { getProductQuantityText } from "@/utils/product"; import { getProductPhotoURL, getProductQuantityText } from "@/utils/product";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
+98 -18
View File
@@ -20,24 +20,46 @@
@refresh="refresh" @refresh="refresh"
@scrolltolower="loadMore" @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 <t-cell
v-for="(record, index) in recordList" v-for="(record, index) in recordList"
:key="record.id || `${record.occurredAt}-${record.requestNo}-${index}`" :key="record.id || `${record.occurredAt}-${record.requestNo}-${index}`"
class="record" class="item"
:arrow="!!record.id" :arrow="!!record.id"
:hover="!!record.id" :hover="!!record.id"
v-entrance-transition="index * 30" v-entrance-transition="index * 30"
@click="toDetail(record)" @click="toDetail(record)"
> >
<template #leftIcon> <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" /> <t-icon :name="getOperationIcon(record.operationType)" size="1.25rem" />
</span> </span>
</template> </template>
<template #title> <template #title>
<div class="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 <span
v-if="record.quantity !== undefined && record.quantity !== null && record.quantity !== ''" v-if="record.quantity !== undefined && record.quantity !== null && record.quantity !== ''"
class="quantity" class="quantity"
@@ -45,18 +67,16 @@
/> />
</div> </div>
</template> </template>
<template #note> <template #description>
<div class="description">
<pass-time-label :timestamp="record.occurredAt" underline />
<div class="delta">
<span>库存变更:</span>
<span <span
class="delta" class="value"
:class="getDeltaClass(record.onHandDelta)" :class="getDeltaClass(record.onHandDelta)"
v-text="getDeltaText(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 />
</div> </div>
</div> </div>
</template> </template>
@@ -71,6 +91,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue"; import { type DropdownValue } from "tdesign-mobile-vue";
import { PassTimeLabel } from "timi-web"; import { PassTimeLabel } from "timi-web";
import { UserAPI } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI"; import ProductAPI from "@/api/ProductAPI";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { StockOperationType, StockRecord, StockRecordDirection } from "@/types/Product"; import type { StockOperationType, StockRecord, StockRecordDirection } from "@/types/Product";
@@ -78,6 +99,8 @@ import { getErrorMessage } from "@/utils/error";
import { getProductQuantityText, getStockOperationText } from "@/utils/product"; import { getProductQuantityText, getStockOperationText } from "@/utils/product";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
import { getCustomerPhotoURL } from "@/utils/customer";
import { buildFallbackAvatar } from "@/utils/avatar";
defineOptions({ name: "ProductStockRecordList" }); defineOptions({ name: "ProductStockRecordList" });
@@ -159,6 +182,18 @@ function getStockQuantityText(record: StockRecord): string {
return `${getProductQuantityText(record.quantity)}${unitText}`; 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 { function getDeltaText(value?: number | string): string {
const amount = Number(value || 0); const amount = Number(value || 0);
const text = String(value || "0").replace(/^-/, ""); const text = String(value || "0").replace(/^-/, "");
@@ -340,7 +375,24 @@ onUnmounted(() => {
.list { .list {
margin: 0; 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 { .icon {
width: 2.25rem; width: 2.25rem;
@@ -354,23 +406,51 @@ onUnmounted(() => {
.title { .title {
gap: .5rem; gap: .5rem;
min-width: 0;
max-width: 100%;
flex: 1;
display: flex; display: flex;
font-size: .75rem;
align-items: center; 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 { .quantity {
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
font-size: .875rem; flex: 0 0 auto;
font-weight: 400; font-weight: 400;
} }
} }
.description {
gap: .5rem;
display: flex;
font-size: .75rem;
align-items: center;
justify-content: space-between;
.delta { .delta {
.value {
font-size: 1.25rem; font-size: 1.25rem;
} }
}
.description {
gap: .125rem;
display: grid;
} }
} }
} }
+14 -2
View File
@@ -32,7 +32,14 @@
@click="toDetail(item)" @click="toDetail(item)"
> >
<template #leftIcon> <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>
<template #title> <template #title>
<div class="title"> <div class="title">
@@ -71,7 +78,7 @@ import ProductAPI from "@/api/ProductAPI";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { Product, ProductStock } from "@/types/Product"; import type { Product, ProductStock } from "@/types/Product";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { getProductQuantityText } from "@/utils/product"; import { getProductPhotoURL, getProductQuantityText } from "@/utils/product";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
@@ -102,6 +109,11 @@ const emptyText = computed(() => {
} }
return "该产品暂无启用库存管理的 SKU"; return "该产品暂无启用库存管理的 SKU";
}); });
function getSkuPhotoURL(item: ProductStock): string {
return item.sku ? getProductPhotoURL(item.sku) : "";
}
let keywordTimer: number | undefined; let keywordTimer: number | undefined;
let requestToken = 0; let requestToken = 0;
let suppressKeywordFetch = false; let suppressKeywordFetch = false;
+5 -52
View File
@@ -187,6 +187,7 @@ import type {
ConsumptionRecordSourceType, ConsumptionRecordSourceType,
ConsumptionRecordStatus ConsumptionRecordStatus
} from "@/types/ConsumptionRecord"; } from "@/types/ConsumptionRecord";
import { buildPresetDateRange, parseDateFilterType, parseDateTime } from "@/utils/date";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { getCustomerPhotoURL } from "@/utils/customer"; import { getCustomerPhotoURL } from "@/utils/customer";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
@@ -217,7 +218,7 @@ const customerId = ref("");
const selectedCustomer = ref<Customer>(); const selectedCustomer = ref<Customer>();
const customerSelectorVisible = ref(false); const customerSelectorVisible = ref(false);
const dateFilterType = ref<DateFilterType>("all"); const dateFilterType = ref<DateFilterType>("all");
const dateRange = ref<[string, string]>(buildPresetRange("all")); const dateRange = ref<[string, string]>(buildPresetDateRange("all"));
const startDraft = ref(""); const startDraft = ref("");
const endDraft = ref(""); const endDraft = ref("");
const startVisible = ref(false); const startVisible = ref(false);
@@ -247,7 +248,7 @@ function applyRouteQuery(): void {
customerId.value = getText(route.query.customerId); customerId.value = getText(route.query.customerId);
sourceType.value = ["ALL", "LEGACY_EVENT", "SALES_ORDER"].includes(routeSourceType) ? routeSourceType as ConsumptionRecordSourceType | "ALL" : "ALL"; 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"; 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); dateRange.value = parseDateRange(dateFilterType.value, getText);
} }
@@ -270,7 +271,7 @@ async function selectDateFilter(value: DropdownValue): Promise<void> {
dateFilterType.value = String(value) as DateFilterType; dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") { if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value); dateRange.value = buildPresetDateRange(dateFilterType.value);
await reloadRecords(); await reloadRecords();
} }
} }
@@ -368,49 +369,11 @@ function buildPage(): ConsumptionRecordPage {
function parseDateRange(type: DateFilterType, getText: (value: unknown) => string): [string, string] { function parseDateRange(type: DateFilterType, getText: (value: unknown) => string): [string, string] {
if (type !== "custom") { if (type !== "custom") {
return buildPresetRange(type); return buildPresetDateRange(type);
} }
return [getText(route.query.beginAt), getText(route.query.endAt)]; 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] { function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") { if (dateFilterType.value === "all") {
return [undefined, undefined]; 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> { async function reloadRecords(value?: DropdownValue): Promise<void> {
if (value && Array.isArray(value)) { if (value && Array.isArray(value)) {
return; return;
+63 -9
View File
@@ -18,7 +18,7 @@
</t-dropdown-menu> </t-dropdown-menu>
</div> </div>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore"> <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 <t-cell
class="item" class="item"
v-for="(item, index) in recordList" v-for="(item, index) in recordList"
@@ -27,18 +27,33 @@
v-entrance-transition="index * 30" v-entrance-transition="index * 30"
@click="toDetail(item)" @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> <template #title>
<div class="title"> <div class="title">
<span class="customer" v-text="getCustomerText(item)" />
<div class="operation">
<span v-text="getFundOperationText(item.operationType)" /> <span v-text="getFundOperationText(item.operationType)" />
<t-tag <t-tag
size="small"
:content="getRecordStatusText(item)" :content="getRecordStatusText(item)"
variant="light-outline" variant="light-outline"
:theme="getRecordStatusTheme(item)" :theme="getRecordStatusTheme(item)"
/> />
</div> </div>
</div>
</template> </template>
<template #description> <template #description>
<div class="description"> <div class="description">
<div class="operator">
<div class="field"> <div class="field">
<span class="label">发起人</span> <span class="label">发起人</span>
<span class="text-bold" v-text="getOperatorText(item)" /> <span class="text-bold" v-text="getOperatorText(item)" />
@@ -47,12 +62,8 @@
<span class="label">核销人</span> <span class="label">核销人</span>
<span class="text-bold" v-text="getVerifierText(item)" /> <span class="text-bold" v-text="getVerifierText(item)" />
</div> </div>
<div>
<pass-time-label :timestamp="item.occurredAt" underline /> <pass-time-label :timestamp="item.occurredAt" underline />
</div> </div>
</div>
</template>
<template #note>
<div class="amounts"> <div class="amounts">
<div class="order-amount"> <div class="order-amount">
<span class="label">订单总价</span> <span class="label">订单总价</span>
@@ -70,6 +81,7 @@
<span class="value red" v-text="formatPackageAmount(item.amount)" /> <span class="value red" v-text="formatPackageAmount(item.amount)" />
</div> </div>
</div> </div>
</div>
</template> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
@@ -90,6 +102,7 @@ import { formatPackageAmount } from "@/utils/package";
import { getFundOperationText, getFundStatusText } from "@/utils/salesOrder"; import { getFundOperationText, getFundStatusText } from "@/utils/salesOrder";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
import { getCustomerPhotoURL } from "@/utils/customer";
import { PassTimeLabel } from "timi-web"; import { PassTimeLabel } from "timi-web";
defineOptions({ name: "FundRecordList" }); defineOptions({ name: "FundRecordList" });
@@ -184,6 +197,10 @@ function getVerifierText(item: FundRecord): string {
return item.verifier?.name || item.verifier?.nick || item.verifierUserId || "未核销"; 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" { function getStatusTheme(statusValue?: FundRecordStatus): "success" | "warning" | "danger" | "default" {
return statusValue === "VERIFIED" ? "success" : statusValue === "PENDING" ? "warning" : statusValue === "REJECTED" ? "danger" : "default"; return statusValue === "VERIFIED" ? "success" : statusValue === "PENDING" ? "warning" : statusValue === "REJECTED" ? "danger" : "default";
} }
@@ -276,13 +293,40 @@ onUnmounted(() => navBarStore.clearRight());
.item { .item {
.avatar {
width: 3.5rem;
height: 3.5rem;
}
.title { .title {
gap: .5rem; gap: .5rem;
display: flex; display: flex;
font-size: .75rem;
align-items: center; 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 { .description {
display: flex;
align-items: start;
justify-content: space-between;
.operator {
gap: 0; gap: 0;
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
display: flex; display: flex;
@@ -304,6 +348,7 @@ onUnmounted(() => navBarStore.clearRight());
.amounts { .amounts {
display: flex; display: flex;
font-size: .75rem; font-size: .75rem;
line-height: 1.5;
align-items: end; align-items: end;
flex-direction: column; flex-direction: column;
@@ -314,14 +359,10 @@ onUnmounted(() => navBarStore.clearRight());
.order-amount, .order-amount,
.unpaid-amount, .unpaid-amount,
.record-amount { .record-amount {
gap: .125rem;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
white-space: nowrap; white-space: nowrap;
} }
.record-amount .value {
font-size: 1.25rem;
} }
} }
} }
@@ -332,4 +373,17 @@ onUnmounted(() => navBarStore.clearRight());
text-align: center; 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> </style>
+76 -12
View File
@@ -165,11 +165,14 @@
<t-switch <t-switch
v-model="fulfillAll" v-model="fulfillAll"
size="small" size="small"
:disabled="!canFulfillImmediately" :disabled="!canFulfillImmediately || isStockChecking"
/> />
</t-form-item> </t-form-item>
<t-form-item label-width="0"> <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>
<t-form-item v-if="(paymentVerified && paymentAmount) || (receiveFullPayment && totalAmount)" label-width="0"> <t-form-item v-if="(paymentVerified && paymentAmount) || (receiveFullPayment && totalAmount)" label-width="0">
<photo-scroller <photo-scroller
@@ -230,15 +233,18 @@ import CodeScanner from "@/components/CodeScanner.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue"; import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue"; import CustomerSelector from "@/components/CustomerSelector.vue";
import PhotoScroller from "@/components/PhotoScroller.vue"; import PhotoScroller from "@/components/PhotoScroller.vue";
import ProductStockPreview from "@/components/ProductStockPreview.vue";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product"; import type { Product, ProductSku, ProductSkuUnit } from "@/types/Product";
import type { PhotoItem } from "@/types/Photo"; import type { PhotoItem } from "@/types/Photo";
import type { DirectProductOrderItemRequest } from "@/types/SalesOrder"; 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 { formatProductAmount, getProductQuantityStep } from "@/utils/product";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
import { buildProductStockPreview, getProductStockShortageMessage, mergeProductStockRequirements } from "@/utils/productStock";
import type { ProductStockPreviewItem } from "@/utils/productStock";
import { uploadPhotoList } from "@/utils/photo"; import { uploadPhotoList } from "@/utils/photo";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import { useSettingStore } from "@/store/settingStore"; 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 fulfillmentPhotoUrlList = computed(() => fulfillmentPhotoItems.value.map(item => item.url));
const receiveFullPayment = ref(false); const receiveFullPayment = ref(false);
const fulfillAll = ref(false); const fulfillAll = ref(false);
const isStockChecking = ref(false);
const stockPreviewList = ref<ProductStockPreviewItem[]>([]);
let stockCheckToken = 0;
const paymentAmount = computed(() => parsePackageAmount(paymentAmountText.value)); const paymentAmount = computed(() => parsePackageAmount(paymentAmountText.value));
const paymentVerified = computed(() => !receiveFullPayment.value const paymentVerified = computed(() => !receiveFullPayment.value
&& 0 < paymentAmountText.value.trim().length && 0 < paymentAmountText.value.trim().length
@@ -289,12 +298,30 @@ const canReceiveImmediately = computed(() => userStore.hasPermissions(permission
&& userStore.hasPermissions(permissionCodes.fundReceive.verify)); && userStore.hasPermissions(permissionCodes.fundReceive.verify));
const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create) const canFulfillImmediately = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)
&& userStore.hasPermissions(permissionCodes.orderFulfillment.verify) && userStore.hasPermissions(permissionCodes.orderFulfillment.verify)
&& userStore.hasPermissions(permissionCodes.productStock.read)
&& (totalAmount.value === 0 || canReceiveImmediately.value)); && (totalAmount.value === 0 || canReceiveImmediately.value));
watch(fulfillAll, value => { watch(fulfillAll, async value => {
if (value && totalAmount.value !== 0) { 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; receiveFullPayment.value = true;
} }
} finally {
if (token === stockCheckToken) {
isStockChecking.value = false;
}
}
}); });
watch(receiveFullPayment, value => { watch(receiveFullPayment, value => {
@@ -465,6 +492,37 @@ function resolveTimestamp(value: string): number | undefined {
function buildAttachments(source: PhotoItem[] = photoItems.value): Partial<Attachment>[] { function buildAttachments(source: PhotoItem[] = photoItems.value): Partial<Attachment>[] {
return source.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []); 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 { function validate(): boolean {
if (!customer.value?.id) { Message.warning("请选择客户"); return false; } if (!customer.value?.id) { Message.warning("请选择客户"); return false; }
if (!items.value.length) { Message.warning("请至少添加一项产品"); return false; } if (!items.value.length) { Message.warning("请至少添加一项产品"); return false; }
@@ -488,7 +546,12 @@ function validate(): boolean {
return true; return true;
} }
async function save(): Promise<void> { 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) { if (paymentVerified.value || receiveFullPayment.value || fulfillAll.value) {
const dialog = DialogPlugin.confirm({ const dialog = DialogPlugin.confirm({
title: "确认直通完成交易", title: "确认直通完成交易",
@@ -515,6 +578,7 @@ async function submitOrder(): Promise<void> {
if (!requestNo.value) requestNo.value = createPackageRequestNo(); if (!requestNo.value) requestNo.value = createPackageRequestNo();
isSaving.value = true; isSaving.value = true;
try { try {
if (fulfillAll.value && !await validateDirectStock()) return;
const itemList = items.value.map((item, index) => ({ const itemList = items.value.map((item, index) => ({
skuId: item.skuId!, skuId: item.skuId!,
skuUnitId: item.skuUnitId!, skuUnitId: item.skuUnitId!,
@@ -600,6 +664,10 @@ onUnmounted(() => navBarStore.clearRight());
} }
} }
.tips {
font-size: .875rem;
}
.product-part { .product-part {
> .header { > .header {
@@ -707,16 +775,12 @@ onUnmounted(() => navBarStore.clearRight());
.textarea { .textarea {
width: 100%; width: 100%;
} }
.photo-part {
padding: 1rem;
}
} }
.submit-wrap { .submit-wrap {
gap: 1rem; gap: 1rem;
left: 2rem; left: 1.5rem;
right: 2rem; right: 1.5rem;
border: 1px solid rgba(0, 0, 0, .05); border: 1px solid rgba(0, 0, 0, .05);
bottom: calc(env(safe-area-inset-bottom, 0) + 1rem); bottom: calc(env(safe-area-inset-bottom, 0) + 1rem);
z-index: 12; z-index: 12;
+12 -8
View File
@@ -96,8 +96,10 @@
</div> </div>
<div class="main"> <div class="main">
<div class="title"> <div class="title">
<div class="name-gift">
<span class="name text-bold" v-text="item.productNameSnapshot || '未命名产品'" /> <span class="name text-bold" v-text="item.productNameSnapshot || '未命名产品'" />
<t-tag v-if="item.gifted" size="small" theme="success" content="赠送" /> <t-tag v-if="item.gifted" size="small" theme="success" content="赠送" />
</div>
<t-tag <t-tag
:content="getItemFulfillmentStatus(item).text" :content="getItemFulfillmentStatus(item).text"
:theme="getItemFulfillmentStatus(item).theme" :theme="getItemFulfillmentStatus(item).theme"
@@ -111,13 +113,6 @@
v-text="`¥${formatPackageAmount(item.lineAmount)}`" v-text="`¥${formatPackageAmount(item.lineAmount)}`"
/> />
<t-tag v-else size="small" content="套餐权益" /> <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-sku-snapshot-tags
:product-name="item.productNameSnapshot" :product-name="item.productNameSnapshot"
:snapshot="item.skuSpecSnapshot" :snapshot="item.skuSpecSnapshot"
@@ -464,7 +459,10 @@ async function toFulfillmentList(): Promise<void> {
async function toCreateFulfillment(): Promise<void> { async function toCreateFulfillment(): Promise<void> {
if (order.value?.id) await router.push({ if (order.value?.id) await router.push({
path: "/management/order-fulfillments/create", 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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.name-gift {
gap: .5rem;
display: flex;
align-items: center;
}
} }
.description { .description {
+24 -6
View File
@@ -15,7 +15,7 @@
<t-form <t-form
v-if="order" v-if="order"
class="form" class="form"
label-width="6rem" label-width="7rem"
v-entrance-transition="70" v-entrance-transition="70"
> >
<t-form-item label="收款金额" required-mark> <t-form-item label="收款金额" required-mark>
@@ -31,8 +31,9 @@
size="extra-small" size="extra-small"
theme="primary" theme="primary"
variant="outline" variant="outline"
content="全额收款"
@click="fillUnpaidAmount" @click="fillUnpaidAmount"
>全额收款</t-button> />
</template> </template>
</t-input> </t-input>
</t-form-item> </t-form-item>
@@ -55,6 +56,12 @@
/> />
</div> </div>
</t-form-item> </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-form-item label="备注">
<t-textarea <t-textarea
v-model="remark" v-model="remark"
@@ -95,7 +102,7 @@ import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, formatPackageAmount, formatPackageAmountInput, parsePackageAmount } from "@/utils/package"; import { createPackageRequestNo, formatPackageAmount, formatPackageAmountInput, parsePackageAmount } from "@/utils/package";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { uploadPhotoList } from "@/utils/photo"; import { uploadPhotoList } from "@/utils/photo";
defineOptions({ name: "SalesOrderPayment" }); defineOptions({ name: "SalesOrderPayment" });
@@ -114,6 +121,10 @@ const remark = ref("");
const photoItems = ref<PhotoItem[]>([]); const photoItems = ref<PhotoItem[]>([]);
const photoUrlList = computed(() => photoItems.value.map(item => item.url)); const photoUrlList = computed(() => photoItems.value.map(item => item.url));
const requestNo = ref(""); 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 { function openOccurredAtPicker(): void {
draftTime.value = occurredAtText.value || Time.formatDateTime(new Date()); draftTime.value = occurredAtText.value || Time.formatDateTime(new Date());
@@ -162,6 +173,10 @@ async function loadData(): Promise<void> {
async function save(): Promise<void> { async function save(): Promise<void> {
if (saving.value || !order.value?.id) return; if (saving.value || !order.value?.id) return;
if (directVerify.value && !canDirectVerify.value) {
Message.warning("当前账号没有店长直接核销权限");
return;
}
const amount = parsePackageAmount(amountText.value); const amount = parsePackageAmount(amountText.value);
const attachmentList = buildAttachments(); const attachmentList = buildAttachments();
if (amount === undefined || amount === 0) { if (amount === undefined || amount === 0) {
@@ -175,15 +190,18 @@ async function save(): Promise<void> {
if (!requestNo.value) requestNo.value = createPackageRequestNo(); if (!requestNo.value) requestNo.value = createPackageRequestNo();
saving.value = true; saving.value = true;
try { try {
const record = await SalesOrderAPI.receivePayment({ const request = {
salesOrderId: order.value.id, salesOrderId: order.value.id,
requestNo: requestNo.value, requestNo: requestNo.value,
amount, amount,
occurredAt: resolveTimestamp(occurredAtText.value), occurredAt: resolveTimestamp(occurredAtText.value),
attachmentList, attachmentList,
remark: remark.value.trim() || null 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) await router.replace(record.id && userStore.hasPermissions(permissionCodes.fundReceive.create)
? `/management/fund-records/${record.id}` ? `/management/fund-records/${record.id}`
: `/management/sales-orders/${order.value.id}`); : `/management/sales-orders/${order.value.id}`);
+54 -43
View File
@@ -5,10 +5,10 @@
<h1 v-text="appName" /> <h1 v-text="appName" />
<p v-text="appDescription" /> <p v-text="appDescription" />
</section> </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 <t-cell
v-for="item in agreementItemList" v-for="item in agreementItemList"
:key="item.code" :key="item.id"
:title="item.title" :title="item.title"
arrow arrow
hover hover
@@ -19,6 +19,10 @@
</template> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
<t-empty
v-else
:description="isLoading ? '正在加载协议配置' : '暂无可用协议'"
/>
<footer class="footer" v-entrance-transition="140"> <footer class="footer" v-entrance-transition="140">
<p>技术支持:夜雨</p> <p>技术支持:夜雨</p>
<p>联系技术:imyeyu@qq.com</p> <p>联系技术:imyeyu@qq.com</p>
@@ -29,17 +33,14 @@
</template> </template>
<script setup lang="ts"> <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 Logo from "@/pages/user/component/Logo.vue";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
type AgreementCode = "SERVICE_LICENSE" | "PRIVACY_LICENSE" | "OPEN_SOURCE_LICENSE";
type AgreementRouteName = "ServiceAgreement" | "PrivacyAgreement" | "OpenSourceAgreement";
type AgreementItem = { type AgreementItem = {
code: AgreementCode;
icon: string; icon: string;
routeName: AgreementRouteName; id: string;
title: string; title: string;
}; };
@@ -51,53 +52,63 @@ const appName = "糕店客户管理";
const appDescription = "面向门店的客户、登记、积分与经营管理工具"; const appDescription = "面向门店的客户、登记、积分与经营管理工具";
const router = useRouter(); const router = useRouter();
const agreementItemList: AgreementItem[] = [ const agreementItemList = ref<AgreementItem[]>([]);
{ const isLoading = ref(false);
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() || "";
}
async function openAgreement(item: AgreementItem): Promise<void> { async function openAgreement(item: AgreementItem): Promise<void> {
const articleId = getAgreementId(item.code);
if (!articleId) {
Message.warning(`${item.title}暂未配置`);
return;
}
await router.push({ await router.push({
name: item.routeName, name: "Agreement",
params: { 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 { 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) { } catch (error) {
Message.error(getErrorMessage(error, "协议配置加载失败")); Message.error(getErrorMessage(error, "协议配置加载失败"));
} finally {
isLoading.value = false;
} }
} }
+6
View File
@@ -16,6 +16,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeRouteLeave } from "vue-router";
import { type Article, ArticleAPI, AttachmentAPI } from "timi-web-api"; import { type Article, ArticleAPI, AttachmentAPI } from "timi-web-api";
import { ArticleView } from "timi-tdesign-mobile"; import { ArticleView } from "timi-tdesign-mobile";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
@@ -70,6 +71,11 @@ async function loadArticle(): Promise<void> {
watch(() => route.params.id, async () => { watch(() => route.params.id, async () => {
await loadArticle(); await loadArticle();
}, { immediate: true }); }, { immediate: true });
onBeforeRouteLeave(() => {
loadToken += 1;
navBarStore.setTitle(getRouteNavBarTitle());
});
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
+5 -13
View File
@@ -36,15 +36,16 @@
</template> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
<div v-if="!isCreateMode && !isStoreManagerScope" class="actions" v-entrance-transition="140">
<t-button <t-button
v-if="!isCreateMode && !isStoreManagerScope"
v-can="permissionCodes.store.delete" v-can="permissionCodes.store.delete"
class="action" class="cell-button"
theme="danger" theme="danger"
:loading="isRemoving" :loading="isRemoving"
content="删除"
v-entrance-transition="140"
@click="confirmRemoveStore" @click="confirmRemoveStore"
>删除</t-button> />
</div>
<store-remove-dialog <store-remove-dialog
v-model:visible="removeDialogVisible" v-model:visible="removeDialogVisible"
:store="formData" :store="formData"
@@ -213,15 +214,6 @@ async function removeStore(id: string): Promise<void> {
.links { .links {
margin: 1rem 0; margin: 1rem 0;
} }
.actions {
margin: 0 1rem;
display: flex;
.action {
flex: 1;
}
}
} }
</style> </style>
+50 -15
View File
@@ -8,38 +8,57 @@
<h2 v-text="group.title"/> <h2 v-text="group.title"/>
<div class="items" :class="group.layout"> <div class="items" :class="group.layout">
<div <div
v-entrance-transition="index * 70"
v-for="(item, index) in group.entryList" v-for="(item, index) in group.entryList"
:key="item.path" :key="item.path"
class="item-wrap"
v-entrance-transition="index * 70"
>
<t-button
class="item" class="item"
:class="{ quick: item.path === '/customer/quick-sign-in' }" :class="{ quick: item.path === '/customer/quick-sign-in' }"
@click="go(item.path)" variant="text"
@click="router.push(item.path)"
> >
<div class="body"> <div class="body">
<t-icon :name="item.icon" size="1.75rem"/> <t-icon :name="item.icon" size="1.75rem"/>
<h2 v-text="item.title"/> <h2 v-text="item.title"/>
</div> </div>
</t-button>
</div> </div>
</div> </div>
</section> </section>
<div class="help-wrap" v-entrance-transition="visibleEntryCount * 70">
<t-button <t-button
class="help" class="help"
theme="primary" theme="primary"
variant="text" variant="text"
content="使用帮助" content="使用帮助"
@click="go('/management/help')" @click="router.push('/management/help')"
> >
<template #icon> <template #icon>
<t-icon name="help-circle"/> <t-icon name="help-circle"/>
</template> </template>
</t-button> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { NotifyAPI, useUserStore } from "timi-web-api"; import { NotifyAPI, useUserStore } from "timi-web-api";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import { corePermissionCodes, permissionCodes } from "@/utils/permission"; import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({ defineOptions({
name: "HomeTab" name: "HomeTab"
@@ -126,14 +145,13 @@ const visibleEntryGroupList = computed(() => entryGroupList
entryList: group.entryList.filter(item => userStore.hasPermissions(item.permission)) entryList: group.entryList.filter(item => userStore.hasPermissions(item.permission))
})) }))
.filter(group => group.entryList.length)); .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 canReadNotify = computed(() => userStore.hasPermissions(corePermissionCodes.notify.read));
const canUseAi = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager));
async function go(path: string): Promise<void> { async function openAiChat(): Promise<void> {
await router.push(path); await router.push("/ai/chat");
}
async function goNotifyList(): Promise<void> {
await router.push("/notify");
} }
async function loadUnreadNotifyCount(): Promise<void> { async function loadUnreadNotifyCount(): Promise<void> {
@@ -151,7 +169,9 @@ async function loadUnreadNotifyCount(): Promise<void> {
onMounted(async () => { onMounted(async () => {
navBarStore.clearRight(); navBarStore.clearRight();
if (canReadNotify.value) { if (canReadNotify.value) {
navBarStore.setRightIcon("notification", goNotifyList); navBarStore.setRightIcon("notification", async () => {
await router.push("/notify");
});
navBarStore.setRightBadge(0); navBarStore.setRightBadge(0);
await loadUnreadNotifyCount(); await loadUnreadNotifyCount();
} }
@@ -165,7 +185,7 @@ onUnmounted(() => {
<style scoped lang="less"> <style scoped lang="less">
.home-tab { .home-tab {
gap: 2rem; gap: 1rem;
display: flex; display: flex;
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem; padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
flex-direction: column; flex-direction: column;
@@ -184,15 +204,21 @@ onUnmounted(() => {
} }
.items { .items {
--td-button-border-radius: 1.75rem;
gap: .875rem; gap: .875rem;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
.item { .item {
--entry-color: #0f766e; --entry-color: #0f766e;
--entry-color-active: #0a5d56;
--entry-background: rgba(255, 255, 255, .92); --entry-background: rgba(255, 255, 255, .92);
color: var(--entry-color); color: var(--entry-color);
width: 100%;
height: auto;
margin: 0;
padding: 1rem; padding: 1rem;
display: flex; display: flex;
position: relative; position: relative;
@@ -201,11 +227,12 @@ onUnmounted(() => {
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08); box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
align-items: center; align-items: center;
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 1.4rem;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
&.quick { &.quick {
--td-button-default-text-active-bg-color: var(--entry-color-active);
color: var(--entry-background); color: var(--entry-background);
background: var(--entry-color); background: var(--entry-color);
} }
@@ -229,15 +256,23 @@ onUnmounted(() => {
} }
} }
.help { .help-wrap {
--td-button-border-radius: 1rem; --td-button-border-radius: 1rem;
gap: 1rem;
width: fit-content;
margin: 1rem auto 0 auto;
display: flex;
align-items: center;
.help,
.ai {
width: 8rem; width: 8rem;
margin: 0 auto;
background: white; background: white;
box-shadow: var(--td-shadow-3); box-shadow: var(--td-shadow-3);
} }
} }
}
:global(html.theme-dark) { :global(html.theme-dark) {
.entry-page { .entry-page {
+5
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, type RouteLocationNormalized } from "vue-router"; import { createRouter, createWebHistory, type RouteLocationNormalized } from "vue-router";
import RootLayout from "@/layout/RootLayout.vue"; import RootLayout from "@/layout/RootLayout.vue";
import aiRoutes from "@/router/routes/aiRoutes";
import chartRoutes from "@/router/routes/chartRoutes"; import chartRoutes from "@/router/routes/chartRoutes";
import customerRoutes from "@/router/routes/customerRoutes"; import customerRoutes from "@/router/routes/customerRoutes";
import { managementAccountRoutes, managementSettingRoutes, storeRoutes } from "@/router/routes/managementRoutes"; import { managementAccountRoutes, managementSettingRoutes, storeRoutes } from "@/router/routes/managementRoutes";
@@ -92,6 +93,7 @@ const router = createRouter({
}, },
children: [ children: [
...tabRoutes, ...tabRoutes,
...aiRoutes,
...detailRoutes ...detailRoutes
] ]
} }
@@ -130,6 +132,9 @@ router.beforeEach(async (to) => {
async function resolveStoreAccessRedirect(to: RouteLocationNormalized): Promise<string | undefined> { async function resolveStoreAccessRedirect(to: RouteLocationNormalized): Promise<string | undefined> {
const userStore = useUserStore(); const userStore = useUserStore();
const routeName = String(to.name || ""); const routeName = String(to.name || "");
if (routeName === "AiChat" && !userStore.hasRoles(gaoRoleCodes.storeManager)) {
return "/management";
}
const storeDiaryRouteNameList = ["StoreDiaryList", "StoreDiaryCreate", "StoreDiaryDetail", "StoreDiaryEdit"]; const storeDiaryRouteNameList = ["StoreDiaryList", "StoreDiaryCreate", "StoreDiaryDetail", "StoreDiaryEdit"];
if (storeDiaryRouteNameList.includes(routeName)) { if (storeDiaryRouteNameList.includes(routeName)) {
if (!userStore.hasRoles(gaoRoleCodes.storeManager)) { 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" 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", path: "/management/about/service-agreement/:id",
name: "ServiceAgreement", name: "ServiceAgreement",
+1
View File
@@ -52,6 +52,7 @@ export type EventRecordListItemView = {
export type EventRecordPageResult = PageResult<EventRecordListItemView> & { export type EventRecordPageResult = PageResult<EventRecordListItemView> & {
numberValueTotal?: number | null; numberValueTotal?: number | null;
customerCount?: number | null;
}; };
export type EventRecordPage = { export type EventRecordPage = {
+14
View File
@@ -1,4 +1,5 @@
import type { Attachment, Model, PageResult, User } from "timi-web-api"; import type { Attachment, Model, PageResult, User } from "timi-web-api";
import type { Customer } from "@/types/Customer";
export type ProductType = "SERVICE" | "GOODS" | "CONSUMABLE"; export type ProductType = "SERVICE" | "GOODS" | "CONSUMABLE";
export type ProductAttachmentType = "PHOTO" | "OTHER"; export type ProductAttachmentType = "PHOTO" | "OTHER";
@@ -93,6 +94,12 @@ export type Product = {
attachmentList?: Attachment[]; attachmentList?: Attachment[];
} & Model; } & Model;
export type ProductSelection = {
product: Product;
sku: ProductSku;
unit: ProductSkuUnit;
};
export type ProductCategoryPage = { export type ProductCategoryPage = {
storeId?: string; storeId?: string;
status?: ProductCategoryStatus; status?: ProductCategoryStatus;
@@ -126,6 +133,7 @@ export type ProductStockProduct = {
productId: string; productId: string;
productCode: string; productCode: string;
productName: string; productName: string;
attachmentList?: Attachment[];
productType?: ProductType; productType?: ProductType;
skuCount?: number; skuCount?: number;
onHandQuantity?: number | string; onHandQuantity?: number | string;
@@ -141,6 +149,11 @@ export type ProductStockPage = {
size: number; size: number;
}; };
export type ProductStockBatchRequest = {
storeId?: string;
skuIdList: string[];
};
export type StockRecord = { export type StockRecord = {
storeId?: string; storeId?: string;
skuId?: string; skuId?: string;
@@ -172,6 +185,7 @@ export type StockRecord = {
remark?: string | null; remark?: string | null;
operator?: Partial<User>; operator?: Partial<User>;
attachmentList?: Partial<Attachment>[]; attachmentList?: Partial<Attachment>[];
customer?: Customer;
} & Model; } & Model;
export type StockRecordPage = { 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); 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 { export function addPackageQuantity(left: number | string, right: number | string): string | undefined {
const leftParts = toQuantityParts(left); 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 };
}