feat: backend MVP (auth/rbac, catalog, orders, fulfillment, invoices) + specs + scaffolds
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@vmall/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./locales": "./src/locales/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./ui.css": "./src/ui.css"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type {
|
||||
Address,
|
||||
AuthTokens,
|
||||
Cart,
|
||||
Category,
|
||||
Currency,
|
||||
Invoice,
|
||||
InvoiceKind,
|
||||
LocalizedText,
|
||||
Order,
|
||||
OrderStatus,
|
||||
Paged,
|
||||
Product,
|
||||
ProductStatus,
|
||||
Shipment,
|
||||
Shop,
|
||||
Sku,
|
||||
User,
|
||||
} from "./types";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
baseUrl: string;
|
||||
getToken?: () => string | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- request bodies ----
|
||||
|
||||
export interface ProductListQuery {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
category_id?: string;
|
||||
q?: string;
|
||||
shop_id?: string;
|
||||
}
|
||||
|
||||
export interface ProductUpsertBody {
|
||||
category_id?: string | null;
|
||||
slug: string;
|
||||
name: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export interface SkuUpsertBody {
|
||||
sku_code: string;
|
||||
attributes?: Record<string, string>;
|
||||
price_minor: number;
|
||||
currency: string;
|
||||
stock: number;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface ShipmentItemBody {
|
||||
order_item_id: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface CurrencyUpsertBody {
|
||||
code: string;
|
||||
name: LocalizedText;
|
||||
symbol: string;
|
||||
exponent: number;
|
||||
rate_to_base: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ShopProductQuery {
|
||||
page?: number;
|
||||
status?: ProductStatus;
|
||||
}
|
||||
|
||||
export interface ShopOrderQuery {
|
||||
page?: number;
|
||||
status?: OrderStatus;
|
||||
}
|
||||
|
||||
export interface ConvertResult {
|
||||
amount_minor: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
type Query = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
async function request<T>(
|
||||
opts: ApiClientOptions,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
query?: Query,
|
||||
): Promise<T> {
|
||||
const url = new URL(opts.baseUrl + path);
|
||||
if (query) {
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
const token = opts.getToken?.();
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const res = await fetch(url.toString(), {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let code = "UNKNOWN";
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const data = (await res.json()) as { error?: { code?: string; message?: string } };
|
||||
code = data?.error?.code ?? code;
|
||||
message = data?.error?.message ?? message;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
throw new ApiError(res.status, code, message);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export interface ApiClient {
|
||||
register(email: string, password: string, displayName: string): Promise<AuthTokens>;
|
||||
login(email: string, password: string): Promise<AuthTokens>;
|
||||
me(): Promise<User>;
|
||||
listProducts(q?: ProductListQuery): Promise<Paged<Product>>;
|
||||
getProduct(idOrSlug: string): Promise<Product>;
|
||||
listCategories(): Promise<Category[]>;
|
||||
listCurrencies(): Promise<Currency[]>;
|
||||
convert(amountMinor: number, from: string, to: string): Promise<ConvertResult>;
|
||||
getCart(): Promise<Cart>;
|
||||
addCartItem(skuId: string, qty: number): Promise<Cart>;
|
||||
updateCartItem(skuId: string, qty: number): Promise<Cart>;
|
||||
removeCartItem(skuId: string): Promise<Cart>;
|
||||
checkout(shippingAddress: Address, currency: string): Promise<Order[]>;
|
||||
listMyOrders(page?: number): Promise<Paged<Order>>;
|
||||
getOrder(id: string): Promise<Order>;
|
||||
cancelOrder(id: string): Promise<Order>;
|
||||
confirmDelivered(shipmentId: string): Promise<Shipment>;
|
||||
listMyShipments(): Promise<Shipment[]>;
|
||||
requestInvoice(
|
||||
orderId: string,
|
||||
title: string,
|
||||
taxNo: string | null,
|
||||
kind: InvoiceKind,
|
||||
): Promise<Invoice>;
|
||||
listMyInvoices(): Promise<Invoice[]>;
|
||||
shop: {
|
||||
getMyShop(): Promise<Shop>;
|
||||
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
|
||||
createProduct(body: ProductUpsertBody): Promise<Product>;
|
||||
updateProduct(id: string, body: ProductUpsertBody): Promise<Product>;
|
||||
publish(id: string): Promise<Product>;
|
||||
unpublish(id: string): Promise<Product>;
|
||||
upsertSku(productId: string, body: SkuUpsertBody): Promise<Sku>;
|
||||
listOrders(q?: ShopOrderQuery): Promise<Paged<Order>>;
|
||||
createShipment(
|
||||
orderId: string,
|
||||
carrier: string,
|
||||
trackingNo: string,
|
||||
items: ShipmentItemBody[],
|
||||
): Promise<Shipment>;
|
||||
listShipments(): Promise<Shipment[]>;
|
||||
markShipped(id: string): Promise<Shipment>;
|
||||
listInvoices(): Promise<Invoice[]>;
|
||||
issueInvoice(id: string): Promise<Invoice>;
|
||||
};
|
||||
admin: {
|
||||
listUsers(page?: number): Promise<Paged<User>>;
|
||||
setUserRole(id: string, role: string, shopId: string | null): Promise<User>;
|
||||
listShops(): Promise<Shop[]>;
|
||||
createShop(name: LocalizedText, slug: string): Promise<Shop>;
|
||||
setShopStatus(id: string, status: "active" | "suspended"): Promise<Shop>;
|
||||
listOrders(page?: number): Promise<Paged<Order>>;
|
||||
listCurrencies(): Promise<Currency[]>;
|
||||
upsertCurrency(body: CurrencyUpsertBody): Promise<Currency>;
|
||||
setRate(code: string, rateToBase: string): Promise<Currency>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
const r = <T>(m: string, p: string, b?: unknown, q?: Query): Promise<T> =>
|
||||
request<T>(opts, m, p, b, q);
|
||||
return {
|
||||
register: (email, password, displayName) =>
|
||||
r("POST", "/auth/register", { email, password, display_name: displayName }),
|
||||
login: (email, password) => r("POST", "/auth/login", { email, password }),
|
||||
me: () => r("GET", "/auth/me"),
|
||||
listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }),
|
||||
getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`),
|
||||
listCategories: () => r("GET", "/categories"),
|
||||
listCurrencies: () => r("GET", "/currencies"),
|
||||
convert: (amountMinor, from, to) =>
|
||||
r("GET", "/currencies/convert", undefined, { amount_minor: amountMinor, from, to }),
|
||||
getCart: () => r("GET", "/cart"),
|
||||
addCartItem: (skuId, qty) => r("POST", "/cart/items", { sku_id: skuId, qty }),
|
||||
updateCartItem: (skuId, qty) => r("PUT", `/cart/items/${skuId}`, { qty }),
|
||||
removeCartItem: (skuId) => r("DELETE", `/cart/items/${skuId}`),
|
||||
checkout: (shippingAddress, currency) =>
|
||||
r("POST", "/orders/checkout", { shipping_address: shippingAddress, currency }),
|
||||
listMyOrders: (page = 1) => r("GET", "/orders", undefined, { page }),
|
||||
getOrder: (id) => r("GET", `/orders/${id}`),
|
||||
cancelOrder: (id) => r("POST", `/orders/${id}/cancel`),
|
||||
confirmDelivered: (shipmentId) => r("POST", `/shipments/${shipmentId}/confirm-delivered`),
|
||||
listMyShipments: () => r("GET", "/shipments"),
|
||||
requestInvoice: (orderId, title, taxNo, kind) =>
|
||||
r("POST", `/orders/${orderId}/invoice`, { title, tax_no: taxNo, kind }),
|
||||
listMyInvoices: () => r("GET", "/invoices"),
|
||||
shop: {
|
||||
getMyShop: () => r("GET", "/shop/profile"),
|
||||
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
|
||||
createProduct: (body) => r("POST", "/shop/products", body),
|
||||
updateProduct: (id, body) => r("PUT", `/shop/products/${id}`, body),
|
||||
publish: (id) => r("POST", `/shop/products/${id}/publish`),
|
||||
unpublish: (id) => r("POST", `/shop/products/${id}/unpublish`),
|
||||
upsertSku: (productId, body) => r("POST", `/shop/products/${productId}/skus`, body),
|
||||
listOrders: (q = {}) => r("GET", "/shop/orders", undefined, { ...q }),
|
||||
createShipment: (orderId, carrier, trackingNo, items) =>
|
||||
r("POST", `/shop/orders/${orderId}/shipments`, {
|
||||
carrier,
|
||||
tracking_no: trackingNo,
|
||||
items,
|
||||
}),
|
||||
listShipments: () => r("GET", "/shop/shipments"),
|
||||
markShipped: (id) => r("POST", `/shop/shipments/${id}/ship`),
|
||||
listInvoices: () => r("GET", "/shop/invoices"),
|
||||
issueInvoice: (id) => r("POST", `/shop/invoices/${id}/issue`),
|
||||
},
|
||||
admin: {
|
||||
listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }),
|
||||
setUserRole: (id, role, shopId) =>
|
||||
r("PUT", `/admin/users/${id}/role`, { role, shop_id: shopId }),
|
||||
listShops: () => r("GET", "/admin/shops"),
|
||||
createShop: (name, slug) => r("POST", "/admin/shops", { name, slug }),
|
||||
setShopStatus: (id, status) => r("PUT", `/admin/shops/${id}/status`, { status }),
|
||||
listOrders: (page = 1) => r("GET", "/admin/orders", undefined, { page }),
|
||||
listCurrencies: () => r("GET", "/admin/currencies"),
|
||||
upsertCurrency: (body) => r("POST", "/admin/currencies", body),
|
||||
setRate: (code, rateToBase) => r("PUT", `/admin/currencies/${code}/rate`, {
|
||||
rate_to_base: rateToBase,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Currency, LocalizedText } from "./types";
|
||||
|
||||
/** Pick the best translation for a locale, falling back to en then any value. */
|
||||
export function t(text: LocalizedText | null | undefined, locale: string): string {
|
||||
if (!text) return "";
|
||||
return text[locale] ?? text.en ?? Object.values(text)[0] ?? "";
|
||||
}
|
||||
|
||||
/** Format minor-unit amount using the currency's exponent. */
|
||||
export function formatMoney(
|
||||
amountMinor: number,
|
||||
currency: string,
|
||||
exponent = 2,
|
||||
locale = "en",
|
||||
): string {
|
||||
const value = amountMinor / 10 ** exponent;
|
||||
return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(value);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types";
|
||||
export * from "./api";
|
||||
export * from "./format";
|
||||
@@ -0,0 +1,168 @@
|
||||
export default {
|
||||
common: {
|
||||
appName: "VMall",
|
||||
login: "Sign in",
|
||||
logout: "Sign out",
|
||||
register: "Create account",
|
||||
email: "Email",
|
||||
password: "Password",
|
||||
displayName: "Display name",
|
||||
submit: "Submit",
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
create: "Create",
|
||||
search: "Search",
|
||||
loading: "Loading…",
|
||||
empty: "Nothing here yet",
|
||||
back: "Back",
|
||||
actions: "Actions",
|
||||
status: "Status",
|
||||
total: "Total",
|
||||
qty: "Qty",
|
||||
price: "Price",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
all: "All",
|
||||
language: "Language",
|
||||
currency: "Currency",
|
||||
page: "Page",
|
||||
prev: "Prev",
|
||||
next: "Next",
|
||||
error: "Something went wrong",
|
||||
required: "Required",
|
||||
},
|
||||
nav: {
|
||||
home: "Home",
|
||||
products: "Products",
|
||||
categories: "Categories",
|
||||
cart: "Cart",
|
||||
orders: "Orders",
|
||||
invoices: "Invoices",
|
||||
shipments: "Shipments",
|
||||
account: "Account",
|
||||
dashboard: "Dashboard",
|
||||
users: "Users",
|
||||
shops: "Shops",
|
||||
currencies: "Currencies",
|
||||
settings: "Settings",
|
||||
},
|
||||
auth: {
|
||||
welcome: "Welcome back",
|
||||
createAccount: "Create your account",
|
||||
haveAccount: "Already have an account?",
|
||||
noAccount: "No account yet?",
|
||||
badCredentials: "Invalid email or password",
|
||||
},
|
||||
product: {
|
||||
addToCart: "Add to cart",
|
||||
buyNow: "Buy now",
|
||||
stock: "Stock",
|
||||
publish: "Publish",
|
||||
unpublish: "Unpublish",
|
||||
draft: "Draft",
|
||||
published: "Published",
|
||||
unpublished: "Unpublished",
|
||||
newProduct: "New product",
|
||||
editProduct: "Edit product",
|
||||
nameEn: "Name (English)",
|
||||
nameZh: "Name (中文)",
|
||||
descEn: "Description (English)",
|
||||
descZh: "Description (中文)",
|
||||
slug: "Slug",
|
||||
category: "Category",
|
||||
images: "Image URLs (one per line)",
|
||||
skus: "SKUs",
|
||||
addSku: "Add SKU",
|
||||
skuCode: "SKU code",
|
||||
detail: "Product detail",
|
||||
shop: "Shop",
|
||||
},
|
||||
cart: {
|
||||
title: "Shopping cart",
|
||||
checkout: "Checkout",
|
||||
emptyCart: "Your cart is empty",
|
||||
remove: "Remove",
|
||||
subtotal: "Subtotal",
|
||||
},
|
||||
order: {
|
||||
title: "Orders",
|
||||
orderNo: "Order no.",
|
||||
placeOrder: "Place order",
|
||||
shippingAddress: "Shipping address",
|
||||
recipient: "Recipient",
|
||||
phone: "Phone",
|
||||
country: "Country",
|
||||
region: "State / Province",
|
||||
city: "City",
|
||||
line1: "Street address",
|
||||
postalCode: "Postal code",
|
||||
pay: "Pay",
|
||||
cancelOrder: "Cancel order",
|
||||
confirmDelivery: "Confirm delivery",
|
||||
requestInvoice: "Request invoice",
|
||||
status: {
|
||||
pending_payment: "Pending payment",
|
||||
paid: "Paid",
|
||||
fulfilling: "Fulfilling",
|
||||
shipped: "Shipped",
|
||||
completed: "Completed",
|
||||
cancelled: "Cancelled",
|
||||
},
|
||||
},
|
||||
shipment: {
|
||||
title: "Shipments",
|
||||
shipmentNo: "Shipment no.",
|
||||
carrier: "Carrier",
|
||||
trackingNo: "Tracking no.",
|
||||
create: "Create shipment",
|
||||
markShipped: "Mark shipped",
|
||||
status: {
|
||||
pending: "Pending",
|
||||
shipped: "Shipped",
|
||||
delivered: "Delivered",
|
||||
},
|
||||
},
|
||||
invoice: {
|
||||
title: "Invoices",
|
||||
invoiceNo: "Invoice no.",
|
||||
invoiceTitle: "Invoice title",
|
||||
taxNo: "Tax number",
|
||||
kind: "Type",
|
||||
personal: "Personal",
|
||||
company: "Company",
|
||||
issue: "Issue invoice",
|
||||
amount: "Amount",
|
||||
status: {
|
||||
requested: "Requested",
|
||||
issued: "Issued",
|
||||
cancelled: "Cancelled",
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
role: "Role",
|
||||
roles: {
|
||||
platform_admin: "Platform admin",
|
||||
shop_owner: "Shop owner",
|
||||
shop_staff: "Shop staff",
|
||||
customer: "Customer",
|
||||
},
|
||||
assignShop: "Assigned shop",
|
||||
createShop: "Create shop",
|
||||
shopSlug: "Shop slug",
|
||||
shopNameEn: "Shop name (English)",
|
||||
shopNameZh: "Shop name (中文)",
|
||||
suspend: "Suspend",
|
||||
activate: "Activate",
|
||||
active: "Active",
|
||||
suspended: "Suspended",
|
||||
rate: "Rate to base",
|
||||
symbol: "Symbol",
|
||||
exponent: "Minor units",
|
||||
baseCurrency: "Base",
|
||||
enabled: "Enabled",
|
||||
saveCurrency: "Save currency",
|
||||
updateRate: "Update rate",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as en } from "./en";
|
||||
export { default as zh } from "./zh";
|
||||
@@ -0,0 +1,168 @@
|
||||
export default {
|
||||
common: {
|
||||
appName: "VMall 云商城",
|
||||
login: "登录",
|
||||
logout: "退出登录",
|
||||
register: "注册",
|
||||
email: "邮箱",
|
||||
password: "密码",
|
||||
displayName: "昵称",
|
||||
submit: "提交",
|
||||
cancel: "取消",
|
||||
save: "保存",
|
||||
delete: "删除",
|
||||
edit: "编辑",
|
||||
create: "新建",
|
||||
search: "搜索",
|
||||
loading: "加载中…",
|
||||
empty: "暂无数据",
|
||||
back: "返回",
|
||||
actions: "操作",
|
||||
status: "状态",
|
||||
total: "合计",
|
||||
qty: "数量",
|
||||
price: "价格",
|
||||
yes: "是",
|
||||
no: "否",
|
||||
all: "全部",
|
||||
language: "语言",
|
||||
currency: "币种",
|
||||
page: "页",
|
||||
prev: "上一页",
|
||||
next: "下一页",
|
||||
error: "出错了",
|
||||
required: "必填",
|
||||
},
|
||||
nav: {
|
||||
home: "首页",
|
||||
products: "商品",
|
||||
categories: "分类",
|
||||
cart: "购物车",
|
||||
orders: "订单",
|
||||
invoices: "发票",
|
||||
shipments: "发货单",
|
||||
account: "我的",
|
||||
dashboard: "仪表盘",
|
||||
users: "用户",
|
||||
shops: "店铺",
|
||||
currencies: "币种",
|
||||
settings: "设置",
|
||||
},
|
||||
auth: {
|
||||
welcome: "欢迎回来",
|
||||
createAccount: "创建账号",
|
||||
haveAccount: "已有账号?",
|
||||
noAccount: "还没有账号?",
|
||||
badCredentials: "邮箱或密码错误",
|
||||
},
|
||||
product: {
|
||||
addToCart: "加入购物车",
|
||||
buyNow: "立即购买",
|
||||
stock: "库存",
|
||||
publish: "上架",
|
||||
unpublish: "下架",
|
||||
draft: "草稿",
|
||||
published: "已上架",
|
||||
unpublished: "已下架",
|
||||
newProduct: "新建商品",
|
||||
editProduct: "编辑商品",
|
||||
nameEn: "名称(英文)",
|
||||
nameZh: "名称(中文)",
|
||||
descEn: "描述(英文)",
|
||||
descZh: "描述(中文)",
|
||||
slug: "别名",
|
||||
category: "分类",
|
||||
images: "图片链接(每行一个)",
|
||||
skus: "SKU",
|
||||
addSku: "添加 SKU",
|
||||
skuCode: "SKU 编码",
|
||||
detail: "商品详情",
|
||||
shop: "店铺",
|
||||
},
|
||||
cart: {
|
||||
title: "购物车",
|
||||
checkout: "去结算",
|
||||
emptyCart: "购物车是空的",
|
||||
remove: "移除",
|
||||
subtotal: "小计",
|
||||
},
|
||||
order: {
|
||||
title: "订单",
|
||||
orderNo: "订单号",
|
||||
placeOrder: "提交订单",
|
||||
shippingAddress: "收货地址",
|
||||
recipient: "收货人",
|
||||
phone: "电话",
|
||||
country: "国家",
|
||||
region: "省/州",
|
||||
city: "城市",
|
||||
line1: "详细地址",
|
||||
postalCode: "邮编",
|
||||
pay: "支付",
|
||||
cancelOrder: "取消订单",
|
||||
confirmDelivery: "确认收货",
|
||||
requestInvoice: "申请发票",
|
||||
status: {
|
||||
pending_payment: "待支付",
|
||||
paid: "已支付",
|
||||
fulfilling: "履约中",
|
||||
shipped: "已发货",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消",
|
||||
},
|
||||
},
|
||||
shipment: {
|
||||
title: "发货单",
|
||||
shipmentNo: "发货单号",
|
||||
carrier: "承运商",
|
||||
trackingNo: "物流单号",
|
||||
create: "创建发货单",
|
||||
markShipped: "标记发货",
|
||||
status: {
|
||||
pending: "待发货",
|
||||
shipped: "已发货",
|
||||
delivered: "已送达",
|
||||
},
|
||||
},
|
||||
invoice: {
|
||||
title: "发票",
|
||||
invoiceNo: "发票号",
|
||||
invoiceTitle: "抬头",
|
||||
taxNo: "税号",
|
||||
kind: "类型",
|
||||
personal: "个人",
|
||||
company: "企业",
|
||||
issue: "开具发票",
|
||||
amount: "金额",
|
||||
status: {
|
||||
requested: "已申请",
|
||||
issued: "已开具",
|
||||
cancelled: "已作废",
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
role: "角色",
|
||||
roles: {
|
||||
platform_admin: "平台管理员",
|
||||
shop_owner: "店主",
|
||||
shop_staff: "店员",
|
||||
customer: "顾客",
|
||||
},
|
||||
assignShop: "所属店铺",
|
||||
createShop: "创建店铺",
|
||||
shopSlug: "店铺别名",
|
||||
shopNameEn: "店铺名(英文)",
|
||||
shopNameZh: "店铺名(中文)",
|
||||
suspend: "停用",
|
||||
activate: "启用",
|
||||
active: "启用中",
|
||||
suspended: "已停用",
|
||||
rate: "对基准汇率",
|
||||
symbol: "符号",
|
||||
exponent: "小数位",
|
||||
baseCurrency: "基准",
|
||||
enabled: "启用",
|
||||
saveCurrency: "保存币种",
|
||||
updateRate: "更新汇率",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,198 @@
|
||||
// API contract types shared by all three frontends.
|
||||
|
||||
export type LocaleCode = "en" | "zh";
|
||||
|
||||
export interface Money {
|
||||
amount_minor: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface LocalizedText {
|
||||
[locale: string]: string;
|
||||
}
|
||||
|
||||
export type UserRole = "platform_admin" | "shop_owner" | "shop_staff" | "customer";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: UserRole;
|
||||
shop_id: string | null;
|
||||
locale: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface Shop {
|
||||
id: string;
|
||||
name: LocalizedText;
|
||||
slug: string;
|
||||
status: "active" | "suspended";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
name: LocalizedText;
|
||||
slug: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export type ProductStatus = "draft" | "published" | "unpublished";
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
shop_id: string;
|
||||
category_id: string | null;
|
||||
slug: string;
|
||||
name: LocalizedText;
|
||||
description: LocalizedText;
|
||||
images: string[];
|
||||
status: ProductStatus;
|
||||
created_at: string;
|
||||
skus?: Sku[];
|
||||
}
|
||||
|
||||
export interface Sku {
|
||||
id: string;
|
||||
product_id: string;
|
||||
sku_code: string;
|
||||
attributes: Record<string, string>;
|
||||
price_minor: number;
|
||||
currency: string;
|
||||
stock: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Currency {
|
||||
code: string;
|
||||
name: LocalizedText;
|
||||
symbol: string;
|
||||
exponent: number;
|
||||
is_base: boolean;
|
||||
rate_to_base: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
sku_id: string;
|
||||
product_id: string;
|
||||
product_name: LocalizedText;
|
||||
sku_code: string;
|
||||
image: string | null;
|
||||
unit_price_minor: number;
|
||||
currency: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
items: CartItem[];
|
||||
}
|
||||
|
||||
export type OrderStatus =
|
||||
| "pending_payment"
|
||||
| "paid"
|
||||
| "fulfilling"
|
||||
| "shipped"
|
||||
| "completed"
|
||||
| "cancelled";
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
sku_id: string;
|
||||
product_name: LocalizedText;
|
||||
sku_code: string;
|
||||
unit_price_minor: number;
|
||||
qty: number;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
order_no: string;
|
||||
shop_id: string;
|
||||
user_id: string;
|
||||
status: OrderStatus;
|
||||
currency: string;
|
||||
total_minor: number;
|
||||
items: OrderItem[];
|
||||
shipping_address: Address;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type InvoiceKind = "personal" | "company";
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
invoice_no: string;
|
||||
order_id: string;
|
||||
order_no?: string;
|
||||
title: string;
|
||||
tax_no: string | null;
|
||||
kind: InvoiceKind;
|
||||
amount_minor: number;
|
||||
currency: string;
|
||||
status: InvoiceStatus;
|
||||
issued_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
recipient: string;
|
||||
phone: string;
|
||||
country: string;
|
||||
region: string;
|
||||
city: string;
|
||||
line1: string;
|
||||
postal_code: string;
|
||||
}
|
||||
|
||||
export type ShipmentStatus = "pending" | "shipped" | "delivered";
|
||||
|
||||
export interface Shipment {
|
||||
id: string;
|
||||
shipment_no: string;
|
||||
order_id: string;
|
||||
order_no?: string;
|
||||
carrier: string;
|
||||
tracking_no: string;
|
||||
status: ShipmentStatus;
|
||||
items: { order_item_id: string; qty: number }[];
|
||||
shipped_at: string | null;
|
||||
delivered_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type InvoiceStatus = "requested" | "issued" | "cancelled";
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
invoice_no: string;
|
||||
order_id: string;
|
||||
order_no?: string;
|
||||
title: string;
|
||||
tax_no: string | null;
|
||||
kind: "personal" | "company";
|
||||
amount_minor: number;
|
||||
currency: string;
|
||||
status: InvoiceStatus;
|
||||
issued_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Paged<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--border: #e2e5ea;
|
||||
--text: #1c2330;
|
||||
--muted: #66707f;
|
||||
--primary: #2f6fed;
|
||||
--primary-dark: #2058c8;
|
||||
--danger: #d64545;
|
||||
--success: #1f9d63;
|
||||
--warning: #c77d0a;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Inter", -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
a { color: var(--primary); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.container { max-width: 1120px; margin: 0 auto; padding: 0 16px; }
|
||||
|
||||
.topnav {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.topnav-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
height: 56px;
|
||||
}
|
||||
.topnav .brand { font-weight: 700; font-size: 17px; color: var(--text); }
|
||||
.topnav nav { display: flex; gap: 14px; flex: 1; }
|
||||
.topnav nav a { color: var(--muted); font-weight: 500; }
|
||||
.topnav nav a.router-link-active { color: var(--primary); }
|
||||
.topnav .spacer { flex: 1; }
|
||||
|
||||
select, input, textarea {
|
||||
font: inherit;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
}
|
||||
select { width: auto; }
|
||||
input:focus, textarea:focus, select:focus { outline: 2px solid rgba(47, 111, 237, 0.3); border-color: var(--primary); }
|
||||
label { display: block; font-weight: 500; margin-bottom: 4px; color: var(--text); }
|
||||
.field { margin-bottom: 14px; }
|
||||
.field-row { display: flex; gap: 12px; }
|
||||
.field-row .field { flex: 1; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn:hover { background: #f0f2f5; }
|
||||
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.btn.primary:hover { background: var(--primary-dark); }
|
||||
.btn.danger { color: var(--danger); border-color: var(--danger); }
|
||||
.btn.danger:hover { background: #fdf0f0; }
|
||||
.btn.sm { padding: 4px 10px; font-size: 13px; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 20px;
|
||||
}
|
||||
.card + .card { margin-top: 16px; }
|
||||
|
||||
.page { padding: 24px 0 48px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; margin: 0 0 16px; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.page-head .page-title { margin: 0; }
|
||||
|
||||
table.table { width: 100%; border-collapse: collapse; background: var(--surface); }
|
||||
.table th, .table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.table th { color: var(--muted); font-weight: 600; font-size: 13px; }
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: #eef1f5;
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge.green { background: #e4f6ec; color: var(--success); }
|
||||
.badge.blue { background: #e7effd; color: var(--primary); }
|
||||
.badge.orange { background: #fdf1e0; color: var(--warning); }
|
||||
.badge.red { background: #fbe6e6; color: var(--danger); }
|
||||
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.grid.products { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
.product-card { overflow: hidden; padding: 0; display: flex; flex-direction: column; }
|
||||
.product-card img { width: 100%; aspect-ratio: 1; object-fit: cover; background: #eef1f5; }
|
||||
.product-card .body { padding: 12px; }
|
||||
.product-card .name { font-weight: 600; margin-bottom: 4px; }
|
||||
.product-card .price { color: var(--primary); font-weight: 700; }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.row { display: flex; align-items: center; gap: 12px; }
|
||||
.between { justify-content: space-between; }
|
||||
.mt { margin-top: 16px; }
|
||||
.mb { margin-bottom: 16px; }
|
||||
.error-text { color: var(--danger); margin: 8px 0; }
|
||||
|
||||
.form-narrow { max-width: 420px; margin: 40px auto; }
|
||||
|
||||
.auth-layout { display: flex; min-height: 100vh; }
|
||||
.auth-layout aside {
|
||||
width: 220px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.auth-layout aside .brand { font-weight: 700; font-size: 16px; padding: 8px 10px 16px; }
|
||||
.auth-layout aside a {
|
||||
display: block;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.auth-layout aside a:hover { background: #f0f2f5; text-decoration: none; }
|
||||
.auth-layout aside a.router-link-active { background: #e7effd; color: var(--primary); }
|
||||
.auth-layout main { flex: 1; padding: 24px; overflow: auto; }
|
||||
.auth-layout .main-head { display: flex; justify-content: flex-end; gap: 10px; margin-bottom: 12px; }
|
||||
Reference in New Issue
Block a user