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