diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 73b221d..9a7c9a2 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -28,5 +28,6 @@ module.exports = { "no-debugger": "off", "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-types": "off", }, }; diff --git a/src/assets/json/goods1.js b/src/assets/json/goods1.ts similarity index 100% rename from src/assets/json/goods1.js rename to src/assets/json/goods1.ts diff --git a/src/components/layout/RightDrawer.vue b/src/components/layout/RightDrawer.vue index 11fa0a2..5c3f7f9 100644 --- a/src/components/layout/RightDrawer.vue +++ b/src/components/layout/RightDrawer.vue @@ -1,6 +1,6 @@ shopping cart - +
contact us @@ -104,4 +106,9 @@ const toPageCart = () => { }); open.value = false; }; + +import useStore from "@/stores"; + +const { cart } = useStore(); +cart.getCartList(); diff --git a/src/components/message/index.ts b/src/components/message/index.ts new file mode 100644 index 0000000..9097003 --- /dev/null +++ b/src/components/message/index.ts @@ -0,0 +1,33 @@ +import { AppContext } from "vue"; +import _message from "./src/messageList.vue"; +import MessageManager from "./src/instance"; +import { MessageInstance, MessageItem, MessageType } from "./src/type"; +export const isString = (data: any): boolean => typeof data === "string"; + +let msg: MessageManager; +const types = ["text", "success", "warning", "error", "loading"] as const; + +const message = types.reduce((pre, value) => { + pre[value] = (config: MessageInstance, appContext?: AppContext) => { + // 直接传入消息提示内容的情况 + if (isString(config)) { + config = { content: config as string } as MessageItem; + } + + const _config: MessageItem = { type: value as MessageType, ...(config as MessageItem) }; + if (!msg) { + msg = new MessageManager(appContext); + } + return msg!.add(_config as MessageItem); + }; + return pre; +}, {} as any); + +const Message = Object.assign({ + ...message, + removeAll: () => { + msg && msg.clear(); + }, +}); + +export default Message; diff --git a/src/components/message/src/instance.ts b/src/components/message/src/instance.ts new file mode 100644 index 0000000..7fef54a --- /dev/null +++ b/src/components/message/src/instance.ts @@ -0,0 +1,77 @@ +import { AppContext, Ref, createVNode, reactive, ref, render } from "vue"; +import Message from "./messageList.vue"; +import { MessageItem } from "./type"; +import { arrayIndexOf, deepClone } from "@/utils/index"; + +class MessageManager { + private mask: HTMLElement = document.createElement("div"); + private messageList: Ref; + + constructor(appContext?: AppContext) { + this.messageList = ref([]); + this.mask.setAttribute("class", `bp-mask-message`); + + const vm = createVNode(Message, { + list: this.messageList.value, + onRemove: this.remove, + }); + + if (appContext) { + vm.appContext = appContext; + } + render(vm, this.mask); + document.body.appendChild(this.mask); + } + + /** + * 添加消息提示 + * @param {MessageItem} config + * @returns + */ + add = (config: MessageItem) => { + const id = config.id ?? `_bp_message_${Math.random().toString(36).slice(-8)}`; + this.mask.setAttribute("class", `bp-mask-message bp-message-${config.position || "top"}`); + + const message: MessageItem = reactive({ id, ...config }); + + // Check whether the message instance already exists. If has, update the message config, or push new one. + const updateIdx = arrayIndexOf(this.messageList.value, "id", id); + updateIdx !== -1 ? (this.messageList.value[updateIdx] = config) : this.messageList.value.push(message); + + // Handle possible simultaneous removal cases, step up 200ms to make the removal visual experience better. + const len = this.messageList.value.length; + if (len > 1 && this.messageList.value[len - 1]?.duration === message.duration) { + message.duration = message.duration ?? 3000 + 200 * len; + } + + return { + remove: () => this.remove(id), + }; + }; + + /** + * 移除消息提示 + * @param {string} id 消息id + */ + remove = (id: string) => { + for (let i = 0; i < this.messageList.value.length; i++) { + const { id: itemId } = this.messageList.value[i]; + + if (id === itemId) { + this.messageList.value.splice(i, 1); + break; + } + } + }; + + /** 清除消息列表 */ + clear = () => { + const arr = deepClone(this.messageList.value); + for (let i = 0; i < arr.length; i++) { + const element = arr[i]; + this.remove(element.id); + } + }; +} + +export default MessageManager; diff --git a/src/components/message/src/message.vue b/src/components/message/src/message.vue new file mode 100644 index 0000000..fcdd596 --- /dev/null +++ b/src/components/message/src/message.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/components/message/src/messageList.vue b/src/components/message/src/messageList.vue new file mode 100644 index 0000000..ff4d70a --- /dev/null +++ b/src/components/message/src/messageList.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/message/src/style/message.css b/src/components/message/src/style/message.css new file mode 100644 index 0000000..8f6ebbb --- /dev/null +++ b/src/components/message/src/style/message.css @@ -0,0 +1,188 @@ +/* @message-cls: ~"@{prefix}-message"; */ + +.bp-mask-message { + position: fixed; + left: 50%; + transform: translateX(-50%); + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + z-index: 999; +} + +.bp-message-bottom { + bottom: 0; +} +.bp-message-bottom .bp-message-list { + bottom: 40px; +} + +.bp-message-top { + top: 0; +} +.bp-message-top .bp-message-list { + top: 40px; +} +.bp-message-list .bp-message { + min-width: 80px; + width: max-content; + height: 46px; + border-radius: 6px; + padding: 0 18px; + display: inline-flex; + align-items: center; + justify-content: space-between; + margin-top: 14px; + line-height: 1; + text-align: center; + list-style: none; + overflow: hidden; + /* .no-select; */ +} +.bp-message-content { + font-size: 14px; + font-weight: normal; + letter-spacing: 0.2px; + color: #262626; +} + +.bp-message-close { + margin-left: 6px; +} +.bp-icon { + width: 18px; + height: 18px; + fill: #8c8c8c; + cursor: pointer; + padding: 2px; + border-radius: 10px; + transition: all 0.2s ease; +} +.bp-icon &:hover { + fill: #434343; + background-color: #f5f5f5; + transition: all 0.2s ease; +} +.icon-success .bp-icon { + fill: #52c41a; +} +.icon-error .bp-icon { + fill: #f5222d; +} +.icon-warning .bp-icon { + fill: #fa8c16; +} +.bp-message-list .bp-message-icon { + width: 18px; + height: 18px; + margin-right: 6px; +} +.icon-loading .bp-icon { + display: inline-block; + fill: #1677ff; + animation: rotating 0.8s linear infinite; + -webkit-animation: rotating 0.8s linear infinite; +} + +.bp-message-list .bp-message-text, +.bp-message-list .bp-message-loading { + background: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border: 1px solid #f0f0f0; +} +.bp-message-loading .bp-message-content { + color: #262626; +} +.bp-message-list .bp-message-success { + background-color: #edfceb; + border: 1px solid #209124; + box-shadow: 0 4px 12px rgba(32, 145, 36, 0.1); +} +.bp-message-success .bp-message-content { + color: #209124; +} +.bp-message-list .bp-message-warning { + background-color: #fff7e6; + border: 1px solid #d46b08; + box-shadow: 0 4px 12px rgba(212, 107, 8, 0.1); +} +.bp-message-warning .bp-message-content { + color: #d46b08; +} +.bp-message-error { + background-color: #fff1f0; + border: 1px solid #cf1322; + box-shadow: 0 4px 12px rgba(207, 19, 43, 0.1); +} +.bp-message-error .bp-message-content { + color: #cf1322; +} +.bp-message-plain { + background: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border: 1px solid #f0f0f0; +} +.bp-message-plain-content { + color: #262626; +} + +.bp-message-plain-close .bp-icon { + fill: #8c8c8c; +} +.bp-message-plain-close .bp-icon &:hover { + fill: #595959; +} +.bp-message-plain-close .bp-icon &::before { + background-color: #f0f0f0; +} +.bp-message-plain-icon .bp-icon { + width: 18px; + height: 18px; +} + +.bp-message-plain-icon .icon-success .bp-icon { + fill: #52c41a; +} +.bp-message-plain-icon .icon-error .bp-icon { + fill: #f5222d; +} +.bp-message-plain-icon .icon-warning .bp-icon { + fill: #fa8c16; +} +.bp-message-plain-icon .icon-loading .bp-icon { + fill: #1677ff; +} +.bp-message-list { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex-wrap: nowrap; +} + +@keyframes rotating { + from { + transform: rotate(0deg); + -webkit-transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + -webkit-transform: rotate(365deg); + } +} + +.fademsg-move, +.fademsg-enter-active, +.fademsg-leave-active { + transition: all 0.2s cubic-bezier(0, 0, 1, 1); +} +.fademsg-enter-from, +.fademsg-leave-to { + opacity: 0; +} +.fademsg-leave-active { + position: absolute; +} diff --git a/src/components/message/src/type.ts b/src/components/message/src/type.ts new file mode 100644 index 0000000..300127f --- /dev/null +++ b/src/components/message/src/type.ts @@ -0,0 +1,19 @@ +/** 消息提示位置 */ +export type MessagePosition = "top" | "bottom"; + +/** 消息类型 */ +export type MessageType = "text" | "success" | "warning" | "error" | "loading"; + +/** 传入的消息类型 */ +export type MessageInstance = string | MessageItem; + +export interface MessageItem { + id: string; + type?: MessageType; + content: string; + duration?: number; + closeable?: boolean; + plain: boolean; + position: MessagePosition; + onClose?: Function; +} diff --git a/src/components/useUiNotification/index.ts b/src/components/useUiNotification/index.ts new file mode 100644 index 0000000..a020a18 --- /dev/null +++ b/src/components/useUiNotification/index.ts @@ -0,0 +1,70 @@ +import { computed, reactive, useContext } from "vue"; +import type { UiNotification, UseUiNotificationInterface } from "./useUiNotification"; + +interface Notifications { + notifications: UiNotification[]; +} + +const state = reactive({ + notifications: [], +}); +const maxVisibleNotifications = 3; +const timeToLive = 3000; + +/** + * Allows managing and showing notifications to the user. + * + * See the {@link UseUiNotificationInterface} for a list of methods and values available in this composable. + */ +export function useUiNotification(): UseUiNotificationInterface { + const { app } = useContext(); + // @ts-ignore + const cookieMessage = app.$vsf.$magento.config.state.getMessage(); + + const send = (notification: UiNotification) => { + const id = Symbol("id"); + + const dismiss = () => { + const index = state.notifications.findIndex((n) => n.id === id); + + if (index !== -1) state.notifications.splice(index, 1); + + // @ts-ignore + app.$vsf.$magento.config.state.removeMessage(); + }; + + const newNotification = { + ...notification, + id, + dismiss, + }; + + if (!state.notifications.some((stateNotification) => stateNotification.message === notification.message)) { + state.notifications.push(newNotification); + } + + if (state.notifications.length > maxVisibleNotifications) { + state.notifications.shift(); + } + + if (!notification.persist) { + setTimeout(dismiss, timeToLive); + } + }; + + if (cookieMessage) { + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + send(cookieMessage); + // @ts-ignore + app.$vsf.$magento.config.state.removeMessage(); + } + + return { + send, + notifications: computed(() => state.notifications), + }; +} + +export default useUiNotification; +export * from "./useUiNotification"; diff --git a/src/components/useUiNotification/useUiNotification.ts b/src/components/useUiNotification/useUiNotification.ts new file mode 100644 index 0000000..abaac08 --- /dev/null +++ b/src/components/useUiNotification/useUiNotification.ts @@ -0,0 +1,32 @@ +import type { ComputedRef } from "vue"; + +export type UiNotificationType = "secondary" | "info" | "success" | "warning" | "danger"; + +export interface UiNotification { + message: string; + title?: string; + action?: { + text: string; + onClick(...args: any): void; + }; + type: UiNotificationType; + icon: string; + persist?: boolean; + id: symbol; + dismiss?: () => void; +} + +/** + * Data and methods returned from the {@link useUiNotification|useUiNotification()} composable + */ +export interface UseUiNotificationInterface { + /** + * Displays the notification in the UI + */ + send(notification: UiNotification): void; + + /** + * Contains notifications added using the `send` method + */ + notifications: ComputedRef; +} diff --git a/src/main.ts b/src/main.ts index e94e0ea..954dd2c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from "vue"; import "./style.css"; +import "./components/message/src/style/message.css"; import App from "./App.vue"; import { createPinia } from "pinia"; diff --git a/src/types/cart.d.ts b/src/types/cart.d.ts index 8a9476c..c28d802 100644 --- a/src/types/cart.d.ts +++ b/src/types/cart.d.ts @@ -7,8 +7,8 @@ export interface CartItem { // specs: any[]; picture: string; price: string; - nowPrice: string; - nowOriginalPrice: string; + // nowPrice: string; + // nowOriginalPrice: string; selected: boolean; stock: number; count: number; diff --git a/src/types/goods.d.ts b/src/types/goods.d.ts index 51a92f0..900b309 100644 --- a/src/types/goods.d.ts +++ b/src/types/goods.d.ts @@ -64,7 +64,7 @@ export interface GoodsDetail { properties: ProductProperty[]; }; isPreSale: boolean; - isCollect: boolean; + isCollect: boolean | null; recommends: SimilarProduct[] | null; // userAddresses: any[] | null; // 这里可以根据具体地址对象的结构进一步定义类型 similarProducts: SimilarProduct[]; diff --git a/src/utils/index.ts b/src/utils/index.ts index 2f94e4a..7d7e6c2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -20,3 +20,52 @@ export const getPriceRange = (min_amount: string, max_amount: string) => { if (min_amount != max_amount) return `$${min_amount} – $${max_amount}`; else return `$${min_amount}`; }; + +/** + * 找出数组里是否含有某个属性值 + * @param arr + * @param field + * @param fieldValue + * @returns number + */ +export function arrayIndexOf(arr: unknown[], field: string, fieldValue: unknown) { + for (let i = 0; i < arr.length; i++) { + const element: any = arr[i]; + if (element[field] === fieldValue) return i; + } + + return -1; +} +/** + * 对象深拷贝 + * @param tSource + * @returns + */ +export function deepClone(tSource: T, tTarget?: Record | T): T { + if (isArray(tSource)) { + tTarget = tTarget || []; + } else { + tTarget = tTarget || {}; + } + for (const key in tSource) { + if (Object.prototype.hasOwnProperty.call(tSource, key)) { + // eslint-disable-next-line valid-typeof + if (typeof tSource[key] === "object" && typeof tSource[key] !== null) { + tTarget[key] = isArray(tSource[key]) ? [] : ({} as any); + deepClone(tSource[key], tTarget[key]); + } else { + tTarget[key] = tSource[key]; + } + } + } + return tTarget as T; +} + +/** + * 判断对象是否为数组 + * @param obj + * @returns + */ +export const isArray = (obj: any) => { + return obj && typeof obj == "object" && obj instanceof Array; +}; diff --git a/src/views/cart/Index.vue b/src/views/cart/Index.vue index 5ceef21..30c0ef9 100644 --- a/src/views/cart/Index.vue +++ b/src/views/cart/Index.vue @@ -10,7 +10,7 @@ - +
- HK$272.95 + {{ item.price }} @@ -157,4 +157,9 @@ import { SfIconFavorite } from "@storefront-ui/vue"; import { ref } from "vue"; // https://tailwind.nodejs.cn/docs/table-layout const count = ref(1); + +import useStore from "@/stores"; + +const { cart } = useStore(); +cart.getCartList(); diff --git a/src/views/product/Index.vue b/src/views/product/Index.vue index 310462b..06c62b6 100644 --- a/src/views/product/Index.vue +++ b/src/views/product/Index.vue @@ -77,7 +77,7 @@
- +

About Products

@@ -179,9 +179,12 @@ import { ref, onMounted } from "vue"; import { SfButton, useId } from "@storefront-ui/vue"; import { clamp } from "@storefront-ui/shared"; import { useCounter } from "@vueuse/core"; -import { goods as _product } from "../../assets/json/goods"; +import { goods as _product } from "../../assets/json/goods1"; import ProductSpecs from "./ProductSpecs/index.vue"; import { useRouter } from "vue-router"; +import useStore from "@/stores"; +import type { GoodsDetail, CartItem } from "@/types"; +import Message from "@/components/message/index"; const min = ref(1); const max = ref(10); const { count, inc, dec, set } = useCounter(1, { @@ -196,10 +199,11 @@ function handleOnChange(event: Event) { const router = useRouter(); const handleClick = () => { - console.log("handleClick"); + Message.text("这是一条文本类型的消息提示"); + // console.log("handleClick"); - const slug = "warm-winter-cozy-washable-dog-house"; - router.push({ path: `/product/${slug}` }); + // const slug = "warm-winter-cozy-washable-dog-house"; + // router.push({ path: `/product/${slug}` }); }; import { SfIconChevronLeft, SfIconChevronRight, type SfScrollableOnDragEndData } from "@storefront-ui/vue"; import { unrefElement, useIntersectionObserver } from "@vueuse/core"; @@ -284,7 +288,6 @@ const assignRef = (el: Element | ComponentPublicInstance | null, index: number) } }; -import type { GoodsDetail } from "@/types/goods"; const goods = ref(); onMounted(async () => { // console.log(JSON.stringify(_product)); @@ -294,18 +297,51 @@ onMounted(async () => { goods.value = _product; }); +// 获取 XtxSku 组件选中的商品信息 +const skuId = ref(""); +const attrsText = ref(""); // const addToBag = () => {}; -const changeSku = () => { +const changeSku = (value: any) => { // 🔔存储 skuId 用于加入购物车 - // skuId.value = value.skuId || ""; - // // 存储选中规格文本 - // attrsText.value = value.specsText; - // // console.log("当前选择的SKU为信息为", value); - // if (goods.value && value.skuId) { - // // 根据选中规格,更新商品库存,销售价格,原始价格 - // goods.value.inventory = value.inventory; - // goods.value.price = value.price; - // goods.value.oldPrice = value.oldPrice; - // } + skuId.value = value.skuId || ""; + // 存储选中规格文本 + attrsText.value = value.specsText; + // console.log("当前选择的SKU为信息为", value); + if (goods.value && value.skuId) { + // 根据选中规格,更新商品库存,销售价格,原始价格 + goods.value.inventory = value.inventory; + // goods.value.price = value.price; + // goods.value.oldPrice = value.oldPrice; + } +}; +const { cart } = useStore(); +// 加入购物按钮点击 +const addCart = () => { + // 没有 skuId,提醒用户并退出函数 + if (!skuId.value) { + // return message({ type: "warn", text: "请选择完整商品规则~" }); + } + if (!goods.value) return; + // Partial 泛型工具类型 全部 转可选 + const cartItem: CartItem = { + // 🚨🚨 注意数据收集字段名很多坑,小心操作 + // 第一部分:商品详情中有的 + id: goods.value.id, // 商品id + name: goods.value.name, // 商品名称 + picture: goods.value.mainPictures[0], // 图片 + // price: goods.value.price, // 旧价格 + // nowPrice: goods.value.price, // 新价格 + stock: goods.value.inventory, // 库存 + // 第二部分:商品详情中没有的,自己通过响应式数据收集 + count: count.value, // 商品数量 + skuId: skuId.value, // skuId + attrsText: attrsText.value, // 商品规格文本 + // 第三部分:设置默认值即可 + selected: true, // 默认商品选中 + isEffective: true, // 默认商品有效 + } as CartItem; + console.log("😭 cartItem 数据终于准备完毕了", cartItem); + // 调用加入购物车接口 + cart.addCart(cartItem); }; diff --git a/src/views/product/ProductSpecs/index.vue b/src/views/product/ProductSpecs/index.vue index 04c0317..994001f 100644 --- a/src/views/product/ProductSpecs/index.vue +++ b/src/views/product/ProductSpecs/index.vue @@ -1,5 +1,5 @@ - -
@@ -112,16 +68,6 @@
- - Add to bag @@ -187,6 +133,7 @@ defineOptions({ name: "ProductSpecs" }); import getPowerSet from "./power-set"; import { type ComponentPublicInstance, ref } from "vue"; +import useStore from "@/store"; import { useCounter } from "@vueuse/core"; import { SfButton, SfIconAdd, SfIconRemove, useId, SfIconFavorite } from "@storefront-ui/vue"; import type { PropType } from "vue"; @@ -352,6 +299,8 @@ const props = defineProps({ interface Emit { (e: "change", value: SkuEmit): void; + (e: "add"): void; + // addCart } const emit = defineEmits();