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