Compare commits

...

3 Commits

Author SHA1 Message Date
a3bbc0098a 9.17/1 2025-09-17 14:40:16 +08:00
46c5682960 9.16/1 2025-09-16 22:11:19 +08:00
562ec4abf9 后端:公告√
注意数据库新建notice表
2025-09-16 20:03:17 +08:00
136 changed files with 6899 additions and 81 deletions

566
backend/db/db.sql Normal file
View File

@@ -0,0 +1,566 @@
-- =====================================================================
-- 配件查询 App 数据库结构MySQL 8.0
-- 依据:/doc/requirements.md、/doc/functional_spec.md、/doc/architecture.md
-- 注意:不在此文件中创建数据库与用户,请在外部以环境配置完成
-- 字符集统一为 utf8mb4排序规则 utf8mb4_0900_ai_ci
-- =====================================================================
SET NAMES utf8mb4;
SET time_zone = '+00:00';
SET sql_mode = 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
SET FOREIGN_KEY_CHECKS = 0;
-- =====================================================================
-- 基础:租户与用户
-- =====================================================================
CREATE TABLE IF NOT EXISTS shops (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '店铺/租户ID',
name VARCHAR(100) NOT NULL COMMENT '店铺名称',
status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态1启用 0停用',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
KEY idx_shops_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='店铺/租户';
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
shop_id BIGINT UNSIGNED NOT NULL COMMENT '所属店铺',
phone VARCHAR(32) NULL COMMENT '手机号',
name VARCHAR(64) NOT NULL COMMENT '姓名',
role VARCHAR(32) NOT NULL DEFAULT 'staff' COMMENT '角色owner/staff/finance/...',
password_hash VARCHAR(255) NULL COMMENT '密码哈希(若采用短信登录可为空)',
status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态1启用 0停用',
is_owner TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否店主',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_users_shop_phone (shop_id, phone),
KEY idx_users_shop (shop_id),
CONSTRAINT fk_users_shop FOREIGN KEY (shop_id) REFERENCES shops(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户';
-- 第三方身份映射(微信)
CREATE TABLE IF NOT EXISTS user_identities (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
provider ENUM('wechat_mp','wechat_app') NOT NULL COMMENT '身份提供方:小程序/APP',
openid VARCHAR(64) NOT NULL,
unionid VARCHAR(64) NULL,
nickname VARCHAR(64) NULL,
avatar_url VARCHAR(512) NULL,
last_login_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY ux_identity_provider_openid (provider, openid),
UNIQUE KEY ux_identity_unionid (unionid),
KEY idx_identity_user (user_id),
KEY idx_identity_shop (shop_id),
CONSTRAINT fk_identity_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方身份映射(微信)';
-- 微信会话(小程序/APP 临时会话)
CREATE TABLE IF NOT EXISTS wechat_sessions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
provider ENUM('wechat_mp','wechat_app') NOT NULL,
openid VARCHAR(64) NOT NULL,
session_key VARCHAR(128) NOT NULL,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY ux_wechat_session (provider, openid),
KEY idx_wechat_session_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='微信会话(临时)';
CREATE TABLE IF NOT EXISTS system_parameters (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL COMMENT '创建/最后修改人',
`key` VARCHAR(64) NOT NULL COMMENT '参数键',
`value` JSON NOT NULL COMMENT '参数值JSON',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY ux_sysparams_shop_key (shop_id, `key`),
KEY idx_sysparams_shop (shop_id),
CONSTRAINT fk_sysparams_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_sysparams_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统参数(租户级)';
-- =====================================================================
-- 货品域(含价格/库存/图片/别名)
-- =====================================================================
CREATE TABLE IF NOT EXISTS product_categories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,
parent_id BIGINT UNSIGNED NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_categories_shop_name (shop_id, name),
KEY idx_categories_shop (shop_id),
KEY idx_categories_parent (parent_id),
CONSTRAINT fk_categories_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES product_categories(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品类别';
CREATE TABLE IF NOT EXISTS product_units (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(16) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_units_shop_name (shop_id, name),
KEY idx_units_shop (shop_id),
CONSTRAINT fk_units_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_units_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品单位';
CREATE TABLE IF NOT EXISTS global_skus (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(120) NOT NULL COMMENT 'SKU名称',
brand VARCHAR(64) NULL,
model VARCHAR(64) NULL,
spec VARCHAR(128) NULL,
barcode VARCHAR(32) NULL,
unit_id BIGINT UNSIGNED NULL,
tags JSON NULL,
status ENUM('published','offline') NOT NULL DEFAULT 'published',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_global_skus_barcode (barcode),
KEY idx_global_skus_brand_model (brand, model),
CONSTRAINT fk_globalsku_unit FOREIGN KEY (unit_id) REFERENCES product_units(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='全局SKU众包';
CREATE TABLE IF NOT EXISTS products (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
category_id BIGINT UNSIGNED NULL,
unit_id BIGINT UNSIGNED NOT NULL,
brand VARCHAR(64) NULL,
model VARCHAR(64) NULL,
spec VARCHAR(128) NULL,
origin VARCHAR(64) NULL,
barcode VARCHAR(32) NULL,
alias VARCHAR(120) NULL,
description TEXT NULL,
global_sku_id BIGINT UNSIGNED NULL,
safe_min DECIMAL(18,3) NULL,
safe_max DECIMAL(18,3) NULL,
search_text TEXT NULL COMMENT '供全文检索的聚合字段(名称/品牌/型号/规格/别名)',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_products_shop_barcode (shop_id, barcode),
KEY idx_products_shop (shop_id),
KEY idx_products_category (category_id),
KEY idx_products_unit (unit_id),
FULLTEXT KEY ft_products_search (name, brand, model, spec, search_text),
CONSTRAINT fk_products_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_products_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES product_categories(id),
CONSTRAINT fk_products_unit FOREIGN KEY (unit_id) REFERENCES product_units(id),
CONSTRAINT fk_products_globalsku FOREIGN KEY (global_sku_id) REFERENCES global_skus(id),
CONSTRAINT ck_products_safe_range CHECK (safe_min IS NULL OR safe_max IS NULL OR safe_min <= safe_max)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品';
CREATE TABLE IF NOT EXISTS product_aliases (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
alias VARCHAR(120) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_product_alias (product_id, alias),
KEY idx_product_alias_product (product_id),
CONSTRAINT fk_alias_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_alias_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_alias_product FOREIGN KEY (product_id) REFERENCES products(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品别名';
CREATE TABLE IF NOT EXISTS product_prices (
product_id BIGINT UNSIGNED NOT NULL,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
purchase_price DECIMAL(18,2) NOT NULL DEFAULT 0,
retail_price DECIMAL(18,2) NOT NULL DEFAULT 0,
distribution_price DECIMAL(18,2) NOT NULL DEFAULT 0,
wholesale_price DECIMAL(18,2) NOT NULL DEFAULT 0,
big_client_price DECIMAL(18,2) NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (product_id),
KEY idx_prices_shop (shop_id),
CONSTRAINT fk_prices_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
CONSTRAINT fk_prices_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_prices_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT ck_prices_non_negative CHECK (
purchase_price >= 0 AND retail_price >= 0 AND distribution_price >= 0 AND wholesale_price >= 0 AND big_client_price >= 0
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品价格(含四列销售价)';
CREATE TABLE IF NOT EXISTS inventories (
product_id BIGINT UNSIGNED NOT NULL,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
quantity DECIMAL(18,3) NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (product_id),
KEY idx_inventories_shop (shop_id),
CONSTRAINT fk_inv_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
CONSTRAINT fk_inv_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_inv_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT ck_inv_qty_non_negative CHECK (quantity >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='库存';
CREATE TABLE IF NOT EXISTS product_images (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
url VARCHAR(512) NOT NULL,
hash VARCHAR(64) NULL COMMENT '内容哈希(去重)',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY ux_product_image_hash (product_id, hash),
KEY idx_product_images_product (product_id),
CONSTRAINT fk_pimg_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_pimg_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_pimg_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品图片';
-- =====================================================================
-- 往来单位与账户
-- =====================================================================
CREATE TABLE IF NOT EXISTS customers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
phone VARCHAR(32) NULL,
level VARCHAR(32) NULL COMMENT '客户等级标签',
price_level ENUM('retail','distribution','wholesale','big_client') NOT NULL DEFAULT 'retail' COMMENT '默认售价列',
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
remark VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
KEY idx_customers_shop (shop_id),
KEY idx_customers_phone (phone),
CONSTRAINT fk_customers_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_customers_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户';
CREATE TABLE IF NOT EXISTS suppliers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
phone VARCHAR(32) NULL,
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
remark VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
KEY idx_suppliers_shop (shop_id),
KEY idx_suppliers_phone (phone),
CONSTRAINT fk_suppliers_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_suppliers_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='供应商';
CREATE TABLE IF NOT EXISTS accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,
`type` ENUM('cash','bank','alipay','wechat','other') NOT NULL DEFAULT 'cash',
balance DECIMAL(18,2) NOT NULL DEFAULT 0,
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_accounts_shop_name (shop_id, name),
KEY idx_accounts_shop (shop_id),
CONSTRAINT fk_accounts_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_accounts_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='结算账户';
-- =====================================================================
-- 单据域(销售/进货/其他收支/流水)
-- =====================================================================
CREATE TABLE IF NOT EXISTS sales_orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL COMMENT '创建人',
customer_id BIGINT UNSIGNED NULL,
order_no VARCHAR(32) NOT NULL,
order_time DATETIME NOT NULL,
status ENUM('draft','approved','returned','void') NOT NULL DEFAULT 'draft',
amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '应收合计',
paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '已收合计',
remark VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_sales_order_no (shop_id, order_no),
KEY idx_sales_shop_time (shop_id, order_time),
KEY idx_sales_customer (customer_id),
CONSTRAINT fk_sales_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_sales_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_sales_customer FOREIGN KEY (customer_id) REFERENCES customers(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='销售单';
CREATE TABLE IF NOT EXISTS sales_order_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
quantity DECIMAL(18,3) NOT NULL,
unit_price DECIMAL(18,2) NOT NULL,
discount_rate DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '折扣百分比0-100',
amount DECIMAL(18,2) NOT NULL,
PRIMARY KEY (id),
KEY idx_soi_order (order_id),
KEY idx_soi_product (product_id),
CONSTRAINT fk_soi_order FOREIGN KEY (order_id) REFERENCES sales_orders(id) ON DELETE CASCADE,
CONSTRAINT fk_soi_product FOREIGN KEY (product_id) REFERENCES products(id),
CONSTRAINT ck_soi_qty CHECK (quantity > 0),
CONSTRAINT ck_soi_price CHECK (unit_price >= 0),
CONSTRAINT ck_soi_discount CHECK (discount_rate >= 0 AND discount_rate <= 100)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='销售单明细';
CREATE TABLE IF NOT EXISTS purchase_orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
supplier_id BIGINT UNSIGNED NULL,
order_no VARCHAR(32) NOT NULL,
order_time DATETIME NOT NULL,
status ENUM('draft','approved','void') NOT NULL DEFAULT 'draft',
amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '应付合计',
paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '已付合计',
remark VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_purchase_order_no (shop_id, order_no),
KEY idx_purchase_shop_time (shop_id, order_time),
KEY idx_purchase_supplier (supplier_id),
CONSTRAINT fk_purchase_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_purchase_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_purchase_supplier FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='进货单';
CREATE TABLE IF NOT EXISTS purchase_order_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
quantity DECIMAL(18,3) NOT NULL,
unit_price DECIMAL(18,2) NOT NULL,
amount DECIMAL(18,2) NOT NULL,
PRIMARY KEY (id),
KEY idx_poi_order (order_id),
KEY idx_poi_product (product_id),
CONSTRAINT fk_poi_order FOREIGN KEY (order_id) REFERENCES purchase_orders(id) ON DELETE CASCADE,
CONSTRAINT fk_poi_product FOREIGN KEY (product_id) REFERENCES products(id),
CONSTRAINT ck_poi_qty CHECK (quantity > 0),
CONSTRAINT ck_poi_price CHECK (unit_price >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='进货单明细';
CREATE TABLE IF NOT EXISTS payments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
biz_type ENUM('sale','purchase','other') NOT NULL,
biz_id BIGINT UNSIGNED NULL COMMENT '业务表IDsales_orders/purchase_orders/other_transactions',
account_id BIGINT UNSIGNED NOT NULL,
direction ENUM('in','out') NOT NULL COMMENT '收款/付款',
amount DECIMAL(18,2) NOT NULL,
pay_time DATETIME NOT NULL,
remark VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_payments_shop_time (shop_id, pay_time),
KEY idx_payments_biz (biz_type, biz_id),
CONSTRAINT fk_payments_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_payments_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_payments_account FOREIGN KEY (account_id) REFERENCES accounts(id),
CONSTRAINT ck_payments_amount CHECK (amount > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='收付款记录';
CREATE TABLE IF NOT EXISTS other_transactions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
`type` ENUM('income','expense') NOT NULL,
category VARCHAR(64) NOT NULL,
counterparty_type VARCHAR(32) NULL COMMENT 'customer/supplier/other',
counterparty_id BIGINT UNSIGNED NULL,
account_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(18,2) NOT NULL,
tx_time DATETIME NOT NULL,
remark VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
KEY idx_ot_shop_time (shop_id, tx_time),
KEY idx_ot_account (account_id),
CONSTRAINT fk_ot_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_ot_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_ot_account FOREIGN KEY (account_id) REFERENCES accounts(id),
CONSTRAINT ck_ot_amount CHECK (amount > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='其他收入/支出';
-- =====================================================================
-- 配件查询与审核、附件
-- =====================================================================
CREATE TABLE IF NOT EXISTS part_submissions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
model_unique VARCHAR(128) NOT NULL COMMENT '型号(唯一)',
brand VARCHAR(64) NULL,
spec VARCHAR(128) NULL,
size VARCHAR(64) NULL,
aperture VARCHAR(64) NULL,
compatible TEXT NULL COMMENT '适配信息',
status ENUM('draft','pending','rejected','published') NOT NULL DEFAULT 'pending',
reason VARCHAR(255) NULL COMMENT '驳回原因',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY ux_part_model_unique (model_unique),
KEY idx_part_submissions_shop (shop_id),
CONSTRAINT fk_part_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_part_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='配件数据提交(审核)';
CREATE TABLE IF NOT EXISTS attachments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shop_id BIGINT UNSIGNED NULL COMMENT '全局资源可空,本地资源属于租户',
user_id BIGINT UNSIGNED NULL,
owner_type VARCHAR(32) NOT NULL COMMENT '资源归属类型product/part_submission/global_sku/...',
owner_id BIGINT UNSIGNED NOT NULL,
url VARCHAR(512) NOT NULL,
hash VARCHAR(64) NULL,
meta JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY ux_attachments_hash (hash),
KEY idx_attachments_owner (owner_type, owner_id),
CONSTRAINT fk_att_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
CONSTRAINT fk_att_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='通用附件(图片等)';
-- =====================================================================
-- 触发器:维护 products.search_text 聚合字段
-- =====================================================================
DROP TRIGGER IF EXISTS trg_products_ai;
DELIMITER $$
CREATE TRIGGER trg_products_ai AFTER INSERT ON products
FOR EACH ROW
BEGIN
UPDATE products
SET search_text = CONCAT_WS(' ', NEW.name, NEW.brand, NEW.model, NEW.spec)
WHERE id = NEW.id;
END $$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_products_au;
DELIMITER $$
CREATE TRIGGER trg_products_au BEFORE UPDATE ON products
FOR EACH ROW
BEGIN
SET NEW.search_text = CONCAT_WS(' ', NEW.name, NEW.brand, NEW.model, NEW.spec);
END $$
DELIMITER ;
-- 当别名变化时重建 search_text名称/品牌/型号/规格 + 所有别名)
DROP TRIGGER IF EXISTS trg_palias_ai;
DELIMITER $$
CREATE TRIGGER trg_palias_ai AFTER INSERT ON product_aliases
FOR EACH ROW
BEGIN
UPDATE products p
JOIN (
SELECT pa.product_id, GROUP_CONCAT(pa.alias SEPARATOR ' ') AS aliases
FROM product_aliases pa
WHERE pa.product_id = NEW.product_id AND pa.deleted_at IS NULL
GROUP BY pa.product_id
) a ON a.product_id = p.id
SET p.search_text = CONCAT_WS(' ', p.name, p.brand, p.model, p.spec, a.aliases)
WHERE p.id = NEW.product_id;
END $$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_palias_au;
DELIMITER $$
CREATE TRIGGER trg_palias_au AFTER UPDATE ON product_aliases
FOR EACH ROW
BEGIN
UPDATE products p
JOIN (
SELECT pa.product_id, GROUP_CONCAT(pa.alias SEPARATOR ' ') AS aliases
FROM product_aliases pa
WHERE pa.product_id = NEW.product_id AND pa.deleted_at IS NULL
GROUP BY pa.product_id
) a ON a.product_id = p.id
SET p.search_text = CONCAT_WS(' ', p.name, p.brand, p.model, p.spec, a.aliases)
WHERE p.id = NEW.product_id;
END $$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_palias_ad;
DELIMITER $$
CREATE TRIGGER trg_palias_ad AFTER DELETE ON product_aliases
FOR EACH ROW
BEGIN
UPDATE products p
LEFT JOIN (
SELECT pa.product_id, GROUP_CONCAT(pa.alias SEPARATOR ' ') AS aliases
FROM product_aliases pa
WHERE pa.product_id = OLD.product_id AND pa.deleted_at IS NULL
GROUP BY pa.product_id
) a ON a.product_id = p.id
SET p.search_text = CONCAT_WS(' ', p.name, p.brand, p.model, p.spec, COALESCE(a.aliases, ''))
WHERE p.id = OLD.product_id;
END $$
DELIMITER ;
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -0,0 +1,77 @@
## 前后端数据库状态说明
**更新日期**: 2025-09-17
### 概要
- 数据库已落地:已在远程 MySQL `mysql.tonaspace.com``partsinquiry` 库完成初始化(表结构与触发器已创建)。
- 已生成根目录文档:`/doc/database_documentation.md` 已同步线上结构(字段、索引、外键、触发器)。
- 后端代码仍未配置数据源依赖与连接,前端无本地结构化存储方案。
### 已建库与连接信息(用于部署/联调)
- Address: `mysql.tonaspace.com`
- Database: `partsinquiry`
- User: `root`
- 说明:所有结构变更均通过 MysqlMCP 执行并已落地到线上库。
### 角色与模拟数据策略(统一为店长)
- 当前不进行角色划分,系统仅保留“店长”角色。
- 已将所有用户记录统一为:`role='owner'``is_owner=1`
- 前端/后端权限逻辑暂未启用,后续若引入权限体系,再行扩展角色与边界。
### 小程序默认用户(可开关,默认关闭)
- 目的:开发/演示阶段,便于免登录联调。
- 机制:前端在请求头附加 `X-User-Id`(值为张老板 id=2仅当开关开启时。
- 开关:
- 环境变量:`VITE_APP_ENABLE_DEFAULT_USER=true``VITE_APP_DEFAULT_USER_ID=2`
- 或本地存储:`ENABLE_DEFAULT_USER=true``DEFAULT_USER_ID=2`
- 关闭:不设置/置为 `false` 即可停用(生产环境默认关闭)。
- 完全移除:删除 `frontend/common/config.js` 中默认用户配置与 `frontend/common/http.js` 中注入逻辑。
### 后端Spring Boot状态
- 依赖:`pom.xml` 已包含 `spring-boot-starter-web``spring-boot-starter-data-jpa``mysql-connector-j`
- 配置:`application.properties` 使用环境变量注入数据源,已补充 Hikari/JPA新增附件占位图配置
- `attachments.placeholder.image-path`env: `ATTACHMENTS_PLACEHOLDER_IMAGE`
- `attachments.placeholder.url-path`env: `ATTACHMENTS_PLACEHOLDER_URL`,默认 `/api/attachments/placeholder`
- 接口:新增附件相关接口(占位方案):
- POST `/api/attachments`:忽略内容,返回 `{ url: "/api/attachments/placeholder" }`
- GET `/api/attachments/placeholder`:返回本地占位图二进制
- 迁移:仍建议引入 Flyway/Liquibase结构变更继续通过 MysqlMCP 并同步 `/doc/database_documentation.md`
### 前端uni-app数据库状态
- 数据持久化:未见 IndexedDB/WebSQL/SQLite/云数据库使用;页面数据为内置静态数据。
- 本地存储:未见 `uni.setStorage`/`uni.getStorage` 的集中封装或结构化键空间设计。
- 结论:前端当前不涉及本地数据库或结构化存储方案。
### 风险与影响
- 后端未配置数据源与接口,应用无法读写远端库(虽已建表)。
- 无接口契约,前后端仍无法联调涉及数据库的功能。
### 建议的后续行动(不自动执行)
- 在后端引入依赖:`spring-boot-starter-web``spring-boot-starter-data-jpa``mysql-connector-j`
- 配置数据源:使用环境变量注入 `SPRING_DATASOURCE_URL``SPRING_DATASOURCE_USERNAME``SPRING_DATASOURCE_PASSWORD` 等,指向上述远程库。
- 引入迁移工具Flyway/Liquibase管理 DDL后续所有变更继续通过 MysqlMCP 执行,并同步 `/doc/database_documentation.md`
- 增加健康检查与基础 CRUD 接口;在 `/doc/openapi.yaml` 按规范登记并标注实现状态(❌/✅)。
### 前端默认连接策略
- 默认后端地址:`http://192.168.31.193:8080`(可被环境变量/Storage 覆盖)
- 多地址重试:按顺序尝试(去重处理):`[ENV, Storage, 192.168.31.193:8080, 127.0.0.1:8080, localhost:8080]`
- 默认用户:开启(可被环境变量/Storage 关闭),请求自动附带 `X-User-Id`(默认 `2`)。
- 如需关闭:在 Storage 或构建环境中设置 `ENABLE_DEFAULT_USER=false`
### 占位图策略(当前阶段)
- 说明:所有图片上传与展示均统一使用占位图,实际文件存储暂不开发。
- 本地占位图:`C:\Users\21826\Desktop\Wj\PartsInquiry\backend\picture\屏幕截图 2025-08-14 134657.png`
- 配置方式:
- PowerShell当前用户持久化
```powershell
setx ATTACHMENTS_PLACEHOLDER_IMAGE "C:\\Users\\21826\\Desktop\\Wj\\PartsInquiry\\backend\\picture\\屏幕截图 2025-08-14 134657.png"
setx ATTACHMENTS_PLACEHOLDER_URL "/api/attachments/placeholder"
```
- 应用重启后生效;也可在运行环境变量中注入。
- 前端影响:
- `components/ImageUploader.vue` 上传始终得到 `{ url: '/api/attachments/placeholder' }`
- 商品列表/详情展示该占位图地址

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -35,6 +35,25 @@
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Web MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA for MySQL access -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -0,0 +1,73 @@
package com.example.demo.attachment;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/attachments")
public class AttachmentController {
private final AttachmentPlaceholderProperties placeholderProperties;
public AttachmentController(AttachmentPlaceholderProperties placeholderProperties) {
this.placeholderProperties = placeholderProperties;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Map<String, Object>> upload(@RequestParam("file") MultipartFile file,
@RequestParam(value = "ownerType", required = false) String ownerType,
@RequestParam(value = "ownerId", required = false) String ownerId) {
// 占位实现:忽略文件内容,始终返回占位图 URL
String url = StringUtils.hasText(placeholderProperties.getUrlPath()) ? placeholderProperties.getUrlPath() : "/api/attachments/placeholder";
Map<String, Object> body = new HashMap<>();
body.put("url", url);
return ResponseEntity.ok(body);
}
@GetMapping("/placeholder")
public ResponseEntity<Resource> placeholder() throws IOException {
String imagePath = placeholderProperties.getImagePath();
if (!StringUtils.hasText(imagePath)) {
return ResponseEntity.status(404).build();
}
Path path = Path.of(imagePath);
if (!Files.exists(path)) {
return ResponseEntity.status(404).build();
}
Resource resource = new FileSystemResource(path);
String contentType = Files.probeContentType(path);
MediaType mediaType;
try {
mediaType = StringUtils.hasText(contentType) ? MediaType.parseMediaType(contentType) : MediaType.IMAGE_PNG;
} catch (Exception e) {
mediaType = MediaType.APPLICATION_OCTET_STREAM;
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=placeholder")
.cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic())
.contentType(mediaType)
.body(resource);
}
}

View File

@@ -0,0 +1,32 @@
package com.example.demo.attachment;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "attachments.placeholder")
public class AttachmentPlaceholderProperties {
private String imagePath;
private String urlPath = "/api/attachments/placeholder";
public String getImagePath() {
return imagePath;
}
public void setImagePath(String imagePath) {
this.imagePath = imagePath;
}
public String getUrlPath() {
return urlPath;
}
public void setUrlPath(String urlPath) {
this.urlPath = urlPath;
}
}

View File

@@ -0,0 +1,21 @@
package com.example.demo.common;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "app.defaults")
public class AppDefaultsProperties {
private Long shopId = 1L;
private Long userId = 2L;
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
}

View File

@@ -0,0 +1,29 @@
package com.example.demo.dashboard;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/dashboard")
public class DashboardController {
private final DashboardService dashboardService;
public DashboardController(DashboardService dashboardService) {
this.dashboardService = dashboardService;
}
@GetMapping("/overview")
public ResponseEntity<DashboardOverviewResponse> overview(
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestHeader(name = "X-User-Id", required = false) Long userId
) {
return ResponseEntity.ok(dashboardService.getOverviewByUserOrShop(userId, shopId));
}
}

View File

@@ -0,0 +1,36 @@
package com.example.demo.dashboard;
import java.math.BigDecimal;
public class DashboardOverviewResponse {
private BigDecimal todaySalesAmount;
private BigDecimal monthSalesAmount;
private BigDecimal monthGrossProfit;
private BigDecimal stockTotalQuantity;
public DashboardOverviewResponse(BigDecimal todaySalesAmount, BigDecimal monthSalesAmount, BigDecimal monthGrossProfit, BigDecimal stockTotalQuantity) {
this.todaySalesAmount = todaySalesAmount;
this.monthSalesAmount = monthSalesAmount;
this.monthGrossProfit = monthGrossProfit;
this.stockTotalQuantity = stockTotalQuantity;
}
public BigDecimal getTodaySalesAmount() {
return todaySalesAmount;
}
public BigDecimal getMonthGrossProfit() {
return monthGrossProfit;
}
public BigDecimal getStockTotalQuantity() {
return stockTotalQuantity;
}
public BigDecimal getMonthSalesAmount() {
return monthSalesAmount;
}
}

View File

@@ -0,0 +1,80 @@
package com.example.demo.dashboard;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
@Repository
public class DashboardRepository {
@PersistenceContext
private EntityManager entityManager;
public BigDecimal sumTodaySalesOrders(Long shopId) {
Object result = entityManager.createNativeQuery(
"SELECT COALESCE(SUM(amount), 0) FROM sales_orders " +
"WHERE shop_id = :shopId AND status = 'approved' AND DATE(order_time) = CURRENT_DATE()"
).setParameter("shopId", shopId).getSingleResult();
return toBigDecimal(result);
}
public BigDecimal sumMonthGrossProfitApprox(Long shopId) {
Object result = entityManager.createNativeQuery(
"SELECT COALESCE(SUM(soi.amount - soi.quantity * COALESCE(pp.purchase_price, 0)), 0) AS gp " +
"FROM sales_orders so " +
"JOIN sales_order_items soi ON soi.order_id = so.id " +
"LEFT JOIN product_prices pp ON pp.product_id = soi.product_id AND pp.shop_id = so.shop_id " +
"WHERE so.shop_id = :shopId AND so.status = 'approved' " +
"AND so.order_time >= DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') " +
"AND so.order_time < DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)"
).setParameter("shopId", shopId).getSingleResult();
return toBigDecimal(result);
}
public BigDecimal sumMonthSalesOrders(Long shopId) {
Object result = entityManager.createNativeQuery(
"SELECT COALESCE(SUM(amount), 0) FROM sales_orders " +
"WHERE shop_id = :shopId AND status = 'approved' " +
"AND order_time >= DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') " +
"AND order_time < DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)"
).setParameter("shopId", shopId).getSingleResult();
return toBigDecimal(result);
}
public BigDecimal sumTotalInventoryQty(Long shopId) {
Object result = entityManager.createNativeQuery(
"SELECT COALESCE(SUM(quantity), 0) FROM inventories WHERE shop_id = :shopId"
).setParameter("shopId", shopId).getSingleResult();
return toBigDecimal(result);
}
private BigDecimal toBigDecimal(Object value) {
if (value == null) return BigDecimal.ZERO;
if (value instanceof BigDecimal) return (BigDecimal) value;
if (value instanceof Number) return BigDecimal.valueOf(((Number) value).doubleValue());
try {
return new BigDecimal(value.toString());
} catch (Exception e) {
return BigDecimal.ZERO;
}
}
public Long findShopIdByUserId(Long userId) {
if (userId == null) return null;
Object result = entityManager.createNativeQuery(
"SELECT shop_id FROM users WHERE id = :userId LIMIT 1"
).setParameter("userId", userId).getSingleResult();
if (result == null) return null;
if (result instanceof Number) return ((Number) result).longValue();
try {
return Long.valueOf(result.toString());
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,39 @@
package com.example.demo.dashboard;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class DashboardService {
private final DashboardRepository dashboardRepository;
public DashboardService(DashboardRepository dashboardRepository) {
this.dashboardRepository = dashboardRepository;
}
public DashboardOverviewResponse getOverview(long shopId) {
BigDecimal todaySales = dashboardRepository.sumTodaySalesOrders(shopId);
BigDecimal monthSales = dashboardRepository.sumMonthSalesOrders(shopId);
BigDecimal monthGrossProfit = dashboardRepository.sumMonthGrossProfitApprox(shopId);
BigDecimal stockTotalQty = dashboardRepository.sumTotalInventoryQty(shopId);
return new DashboardOverviewResponse(todaySales, monthSales, monthGrossProfit, stockTotalQty);
}
public DashboardOverviewResponse getOverviewByUserOrShop(Long userId, Long shopIdOrNull) {
Long resolvedShopId = null;
if (userId != null) {
resolvedShopId = dashboardRepository.findShopIdByUserId(userId);
}
if (resolvedShopId == null && shopIdOrNull != null) {
resolvedShopId = shopIdOrNull;
}
if (resolvedShopId == null) {
resolvedShopId = 1L;
}
return getOverview(resolvedShopId);
}
}

View File

@@ -0,0 +1,82 @@
package com.example.demo.notice;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "notices")
public class Notice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "title", nullable = false, length = 120)
private String title;
@Column(name = "content", nullable = false, length = 500)
private String content;
@Column(name = "tag", length = 32)
private String tag;
@Column(name = "is_pinned", nullable = false)
private Boolean pinned = false;
@Column(name = "starts_at")
private LocalDateTime startsAt;
@Column(name = "ends_at")
private LocalDateTime endsAt;
@Convert(converter = NoticeStatusConverter.class)
@Column(name = "status", nullable = false, length = 16)
private NoticeStatus status = NoticeStatus.PUBLISHED;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
LocalDateTime now = LocalDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
public Long getId() { return id; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getTag() { return tag; }
public void setTag(String tag) { this.tag = tag; }
public Boolean getPinned() { return pinned; }
public void setPinned(Boolean pinned) { this.pinned = pinned; }
public LocalDateTime getStartsAt() { return startsAt; }
public void setStartsAt(LocalDateTime startsAt) { this.startsAt = startsAt; }
public LocalDateTime getEndsAt() { return endsAt; }
public void setEndsAt(LocalDateTime endsAt) { this.endsAt = endsAt; }
public NoticeStatus getStatus() { return status; }
public void setStatus(NoticeStatus status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}

View File

@@ -0,0 +1,31 @@
package com.example.demo.notice;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/notices")
public class NoticeController {
private final NoticeService noticeService;
public NoticeController(NoticeService noticeService) {
this.noticeService = noticeService;
}
/**
* 简化:通过请求头 X-Shop-Id 传递当前店铺ID。
*/
@GetMapping
public ResponseEntity<List<Notice>> list(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
Long sid = (shopId == null ? 1L : shopId);
return ResponseEntity.ok(noticeService.listActive(sid));
}
}

View File

@@ -0,0 +1,17 @@
package com.example.demo.notice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface NoticeRepository extends JpaRepository<Notice, Long> {
@Query("SELECT n FROM Notice n WHERE n.shopId = :shopId AND n.status = :status " +
"AND (n.startsAt IS NULL OR n.startsAt <= CURRENT_TIMESTAMP) AND (n.endsAt IS NULL OR n.endsAt >= CURRENT_TIMESTAMP) " +
"ORDER BY n.pinned DESC, n.createdAt DESC")
List<Notice> findActiveNotices(@Param("shopId") Long shopId, @Param("status") NoticeStatus status);
}

View File

@@ -0,0 +1,20 @@
package com.example.demo.notice;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class NoticeService {
private final NoticeRepository noticeRepository;
public NoticeService(NoticeRepository noticeRepository) {
this.noticeRepository = noticeRepository;
}
public List<Notice> listActive(Long shopId) {
return noticeRepository.findActiveNotices(shopId, NoticeStatus.PUBLISHED);
}
}

View File

@@ -0,0 +1,12 @@
package com.example.demo.notice;
/**
* 公告状态。
*/
public enum NoticeStatus {
DRAFT,
PUBLISHED,
OFFLINE
}

View File

@@ -0,0 +1,30 @@
package com.example.demo.notice;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = false)
public class NoticeStatusConverter implements AttributeConverter<NoticeStatus, String> {
@Override
public String convertToDatabaseColumn(NoticeStatus attribute) {
if (attribute == null) return null;
return switch (attribute) {
case DRAFT -> "draft";
case PUBLISHED -> "published";
case OFFLINE -> "offline";
};
}
@Override
public NoticeStatus convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
return switch (dbData) {
case "draft" -> NoticeStatus.DRAFT;
case "published" -> NoticeStatus.PUBLISHED;
case "offline" -> NoticeStatus.OFFLINE;
default -> NoticeStatus.PUBLISHED;
};
}
}

View File

@@ -0,0 +1,44 @@
package com.example.demo.product.controller;
import com.example.demo.common.AppDefaultsProperties;
import com.example.demo.product.repo.CategoryRepository;
import com.example.demo.product.repo.UnitRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
public class MetadataController {
private final UnitRepository unitRepository;
private final CategoryRepository categoryRepository;
private final AppDefaultsProperties defaults;
public MetadataController(UnitRepository unitRepository, CategoryRepository categoryRepository, AppDefaultsProperties defaults) {
this.unitRepository = unitRepository;
this.categoryRepository = categoryRepository;
this.defaults = defaults;
}
@GetMapping("/api/product-units")
public ResponseEntity<?> listUnits(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Map<String, Object> body = new HashMap<>();
body.put("list", unitRepository.listByShop(sid));
return ResponseEntity.ok(body);
}
@GetMapping("/api/product-categories")
public ResponseEntity<?> listCategories(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Map<String, Object> body = new HashMap<>();
body.put("list", categoryRepository.listByShop(sid));
return ResponseEntity.ok(body);
}
}

View File

@@ -0,0 +1,66 @@
package com.example.demo.product.controller;
import com.example.demo.common.AppDefaultsProperties;
import com.example.demo.product.dto.ProductDtos;
import com.example.demo.product.service.ProductService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
private final AppDefaultsProperties defaults;
public ProductController(ProductService productService, AppDefaultsProperties defaults) {
this.productService = productService;
this.defaults = defaults;
}
@GetMapping
public ResponseEntity<?> search(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestParam(name = "kw", required = false) String kw,
@RequestParam(name = "categoryId", required = false) Long categoryId,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "50") int size) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Page<ProductDtos.ProductListItem> result = productService.search(sid, kw, categoryId, Math.max(page - 1, 0), size);
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("list", result.getContent());
return ResponseEntity.ok(body);
}
@GetMapping("/{id}")
public ResponseEntity<?> detail(@PathVariable("id") Long id) {
return productService.findDetail(id)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestHeader(name = "X-User-Id", required = false) Long userId,
@RequestBody ProductDtos.CreateOrUpdateProductRequest req) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Long uid = (userId == null ? defaults.getUserId() : userId);
Long id = productService.create(sid, uid, req);
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("id", id);
return ResponseEntity.ok(body);
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable("id") Long id,
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestHeader(name = "X-User-Id", required = false) Long userId,
@RequestBody ProductDtos.CreateOrUpdateProductRequest req) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Long uid = (userId == null ? defaults.getUserId() : userId);
productService.update(id, sid, uid, req);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,72 @@
package com.example.demo.product.dto;
import java.math.BigDecimal;
import java.util.List;
public class ProductDtos {
public static class ProductListItem {
public Long id;
public String name;
public String brand;
public String model;
public String spec;
public BigDecimal stock; // from inventories.quantity
public BigDecimal retailPrice; // from product_prices
public String cover; // first image url
}
public static class ProductDetail {
public Long id;
public String name;
public String barcode;
public String brand;
public String model;
public String spec;
public String origin;
public Long categoryId;
public Long unitId;
public BigDecimal safeMin;
public BigDecimal safeMax;
public BigDecimal stock;
public BigDecimal purchasePrice;
public BigDecimal retailPrice;
public BigDecimal distributionPrice;
public BigDecimal wholesalePrice;
public BigDecimal bigClientPrice;
public List<Image> images;
}
public static class Image {
public String url;
}
public static class CreateOrUpdateProductRequest {
public String name;
public String barcode;
public String brand;
public String model;
public String spec;
public String origin;
public Long categoryId;
public Long unitId;
public BigDecimal safeMin;
public BigDecimal safeMax;
public Prices prices;
public BigDecimal stock;
public List<String> images;
public String remark; // map to products.description
}
public static class Prices {
public BigDecimal purchasePrice;
public BigDecimal retailPrice;
public BigDecimal distributionPrice;
public BigDecimal wholesalePrice;
public BigDecimal bigClientPrice;
}
}

View File

@@ -0,0 +1,41 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "inventories")
public class Inventory {
@Id
@Column(name = "product_id")
private Long productId;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "quantity", precision = 18, scale = 3, nullable = false)
private BigDecimal quantity = BigDecimal.ZERO;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public BigDecimal getQuantity() { return quantity; }
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,100 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "name", nullable = false, length = 120)
private String name;
@Column(name = "category_id")
private Long categoryId;
@Column(name = "unit_id", nullable = false)
private Long unitId;
@Column(name = "brand", length = 64)
private String brand;
@Column(name = "model", length = 64)
private String model;
@Column(name = "spec", length = 128)
private String spec;
@Column(name = "origin", length = 64)
private String origin;
@Column(name = "barcode", length = 32)
private String barcode;
@Column(name = "description")
private String description;
@Column(name = "safe_min", precision = 18, scale = 3)
private BigDecimal safeMin;
@Column(name = "safe_max", precision = 18, scale = 3)
private BigDecimal safeMax;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
public Long getId() { return id; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Long getCategoryId() { return categoryId; }
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
public Long getUnitId() { return unitId; }
public void setUnitId(Long unitId) { this.unitId = unitId; }
public String getBrand() { return brand; }
public void setBrand(String brand) { this.brand = brand; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
public String getSpec() { return spec; }
public void setSpec(String spec) { this.spec = spec; }
public String getOrigin() { return origin; }
public void setOrigin(String origin) { this.origin = origin; }
public String getBarcode() { return barcode; }
public void setBarcode(String barcode) { this.barcode = barcode; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getSafeMin() { return safeMin; }
public void setSafeMin(BigDecimal safeMin) { this.safeMin = safeMin; }
public BigDecimal getSafeMax() { return safeMax; }
public void setSafeMax(BigDecimal safeMax) { this.safeMax = safeMax; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}

View File

@@ -0,0 +1,58 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "product_categories")
public class ProductCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "parent_id")
private Long parentId;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
public Long getId() { return id; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Long getParentId() { return parentId; }
public void setParentId(Long parentId) { this.parentId = parentId; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}

View File

@@ -0,0 +1,43 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "product_images")
public class ProductImage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "product_id", nullable = false)
private Long productId;
@Column(name = "url", nullable = false, length = 512)
private String url;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
public Long getId() { return id; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
}

View File

@@ -0,0 +1,61 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "product_prices")
public class ProductPrice {
@Id
@Column(name = "product_id")
private Long productId;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "purchase_price", precision = 18, scale = 2, nullable = false)
private BigDecimal purchasePrice = BigDecimal.ZERO;
@Column(name = "retail_price", precision = 18, scale = 2, nullable = false)
private BigDecimal retailPrice = BigDecimal.ZERO;
@Column(name = "distribution_price", precision = 18, scale = 2, nullable = false)
private BigDecimal distributionPrice = BigDecimal.ZERO;
@Column(name = "wholesale_price", precision = 18, scale = 2, nullable = false)
private BigDecimal wholesalePrice = BigDecimal.ZERO;
@Column(name = "big_client_price", precision = 18, scale = 2, nullable = false)
private BigDecimal bigClientPrice = BigDecimal.ZERO;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public BigDecimal getPurchasePrice() { return purchasePrice; }
public void setPurchasePrice(BigDecimal purchasePrice) { this.purchasePrice = purchasePrice; }
public BigDecimal getRetailPrice() { return retailPrice; }
public void setRetailPrice(BigDecimal retailPrice) { this.retailPrice = retailPrice; }
public BigDecimal getDistributionPrice() { return distributionPrice; }
public void setDistributionPrice(BigDecimal distributionPrice) { this.distributionPrice = distributionPrice; }
public BigDecimal getWholesalePrice() { return wholesalePrice; }
public void setWholesalePrice(BigDecimal wholesalePrice) { this.wholesalePrice = wholesalePrice; }
public BigDecimal getBigClientPrice() { return bigClientPrice; }
public void setBigClientPrice(BigDecimal bigClientPrice) { this.bigClientPrice = bigClientPrice; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,48 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "product_units")
public class ProductUnit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "name", nullable = false, length = 16)
private String name;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
public Long getId() { return id; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}

View File

@@ -0,0 +1,17 @@
package com.example.demo.product.repo;
import com.example.demo.product.entity.ProductCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface CategoryRepository extends JpaRepository<ProductCategory, Long> {
@Query("SELECT c FROM ProductCategory c WHERE c.shopId = :shopId AND c.deletedAt IS NULL ORDER BY c.sortOrder ASC, c.id DESC")
List<ProductCategory> listByShop(@Param("shopId") Long shopId);
}

View File

@@ -0,0 +1,11 @@
package com.example.demo.product.repo;
import com.example.demo.product.entity.Inventory;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InventoryRepository extends JpaRepository<Inventory, Long> {
}

View File

@@ -0,0 +1,15 @@
package com.example.demo.product.repo;
import com.example.demo.product.entity.ProductImage;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductImageRepository extends JpaRepository<ProductImage, Long> {
List<ProductImage> findByProductIdOrderBySortOrderAscIdAsc(Long productId);
void deleteByProductId(Long productId);
}

View File

@@ -0,0 +1,11 @@
package com.example.demo.product.repo;
import com.example.demo.product.entity.ProductPrice;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductPriceRepository extends JpaRepository<ProductPrice, Long> {
}

View File

@@ -0,0 +1,25 @@
package com.example.demo.product.repo;
import com.example.demo.product.entity.Product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("SELECT p FROM Product p WHERE p.shopId = :shopId AND (p.deletedAt IS NULL) AND " +
"(:kw IS NULL OR :kw = '' OR p.name LIKE CONCAT('%', :kw, '%') OR p.brand LIKE CONCAT('%', :kw, '%') OR p.model LIKE CONCAT('%', :kw, '%') OR p.spec LIKE CONCAT('%', :kw, '%') OR p.barcode LIKE CONCAT('%', :kw, '%')) AND " +
"(:categoryId IS NULL OR p.categoryId = :categoryId) ORDER BY p.id DESC")
Page<Product> search(@Param("shopId") Long shopId,
@Param("kw") String kw,
@Param("categoryId") Long categoryId,
Pageable pageable);
boolean existsByShopIdAndBarcode(Long shopId, String barcode);
}

View File

@@ -0,0 +1,16 @@
package com.example.demo.product.repo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface UnitRepository extends JpaRepository<com.example.demo.product.entity.ProductUnit, Long> {
@Query("SELECT u FROM ProductUnit u WHERE u.shopId = :shopId AND u.deletedAt IS NULL ORDER BY u.id DESC")
List<com.example.demo.product.entity.ProductUnit> listByShop(@Param("shopId") Long shopId);
}

View File

@@ -0,0 +1,216 @@
package com.example.demo.product.service;
import com.example.demo.product.dto.ProductDtos;
import com.example.demo.product.entity.Inventory;
import com.example.demo.product.entity.Product;
import com.example.demo.product.entity.ProductImage;
import com.example.demo.product.entity.ProductPrice;
import com.example.demo.product.repo.InventoryRepository;
import com.example.demo.product.repo.ProductImageRepository;
import com.example.demo.product.repo.ProductPriceRepository;
import com.example.demo.product.repo.ProductRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
public class ProductService {
private final ProductRepository productRepository;
private final ProductPriceRepository priceRepository;
private final InventoryRepository inventoryRepository;
private final ProductImageRepository imageRepository;
public ProductService(ProductRepository productRepository,
ProductPriceRepository priceRepository,
InventoryRepository inventoryRepository,
ProductImageRepository imageRepository) {
this.productRepository = productRepository;
this.priceRepository = priceRepository;
this.inventoryRepository = inventoryRepository;
this.imageRepository = imageRepository;
}
public Page<ProductDtos.ProductListItem> search(Long shopId, String kw, Long categoryId, int page, int size) {
Page<Product> p = productRepository.search(shopId, kw, categoryId, PageRequest.of(page, size));
return p.map(prod -> {
ProductDtos.ProductListItem it = new ProductDtos.ProductListItem();
it.id = prod.getId();
it.name = prod.getName();
it.brand = prod.getBrand();
it.model = prod.getModel();
it.spec = prod.getSpec();
// stock
inventoryRepository.findById(prod.getId()).ifPresent(inv -> it.stock = inv.getQuantity());
// price
priceRepository.findById(prod.getId()).ifPresent(pr -> it.retailPrice = pr.getRetailPrice());
// cover
List<ProductImage> imgs = imageRepository.findByProductIdOrderBySortOrderAscIdAsc(prod.getId());
it.cover = imgs.isEmpty() ? null : imgs.get(0).getUrl();
return it;
});
}
public Optional<ProductDtos.ProductDetail> findDetail(Long id) {
Optional<Product> op = productRepository.findById(id);
if (op.isEmpty()) return Optional.empty();
Product p = op.get();
ProductDtos.ProductDetail d = new ProductDtos.ProductDetail();
d.id = p.getId();
d.name = p.getName();
d.barcode = p.getBarcode();
d.brand = p.getBrand();
d.model = p.getModel();
d.spec = p.getSpec();
d.origin = p.getOrigin();
d.categoryId = p.getCategoryId();
d.unitId = p.getUnitId();
d.safeMin = p.getSafeMin();
d.safeMax = p.getSafeMax();
inventoryRepository.findById(p.getId()).ifPresent(inv -> d.stock = inv.getQuantity());
priceRepository.findById(p.getId()).ifPresent(pr -> {
d.purchasePrice = pr.getPurchasePrice();
d.retailPrice = pr.getRetailPrice();
d.distributionPrice = pr.getDistributionPrice();
d.wholesalePrice = pr.getWholesalePrice();
d.bigClientPrice = pr.getBigClientPrice();
});
List<ProductImage> imgs = imageRepository.findByProductIdOrderBySortOrderAscIdAsc(p.getId());
List<ProductDtos.Image> list = new ArrayList<>();
for (ProductImage img : imgs) {
ProductDtos.Image i = new ProductDtos.Image();
i.url = img.getUrl();
list.add(i);
}
d.images = list;
return Optional.of(d);
}
@Transactional
public Long create(Long shopId, Long userId, ProductDtos.CreateOrUpdateProductRequest req) {
validate(shopId, req);
LocalDateTime now = LocalDateTime.now();
Product p = new Product();
p.setShopId(shopId);
p.setUserId(userId);
p.setName(req.name);
p.setBarcode(emptyToNull(req.barcode));
p.setBrand(emptyToNull(req.brand));
p.setModel(emptyToNull(req.model));
p.setSpec(emptyToNull(req.spec));
p.setOrigin(emptyToNull(req.origin));
p.setCategoryId(req.categoryId);
p.setUnitId(req.unitId);
p.setSafeMin(req.safeMin);
p.setSafeMax(req.safeMax);
p.setDescription(emptyToNull(req.remark));
p.setCreatedAt(now);
p.setUpdatedAt(now);
productRepository.save(p);
upsertPrice(userId, now, p.getId(), shopId, req.prices);
upsertInventory(userId, now, p.getId(), shopId, req.stock);
syncImages(userId, p.getId(), shopId, req.images);
return p.getId();
}
@Transactional
public void update(Long id, Long shopId, Long userId, ProductDtos.CreateOrUpdateProductRequest req) {
validate(shopId, req);
Product p = productRepository.findById(id).orElseThrow();
if (!p.getShopId().equals(shopId)) throw new IllegalArgumentException("跨店铺数据");
p.setUserId(userId);
p.setName(req.name);
p.setBarcode(emptyToNull(req.barcode));
p.setBrand(emptyToNull(req.brand));
p.setModel(emptyToNull(req.model));
p.setSpec(emptyToNull(req.spec));
p.setOrigin(emptyToNull(req.origin));
p.setCategoryId(req.categoryId);
p.setUnitId(req.unitId);
p.setSafeMin(req.safeMin);
p.setSafeMax(req.safeMax);
p.setDescription(emptyToNull(req.remark));
p.setUpdatedAt(LocalDateTime.now());
productRepository.save(p);
LocalDateTime now = LocalDateTime.now();
upsertPrice(userId, now, p.getId(), shopId, req.prices);
upsertInventory(userId, now, p.getId(), shopId, req.stock);
syncImages(userId, p.getId(), shopId, req.images);
}
private void validate(Long shopId, ProductDtos.CreateOrUpdateProductRequest req) {
if (req.name == null || req.name.isBlank()) throw new IllegalArgumentException("name必填");
if (req.unitId == null) throw new IllegalArgumentException("unitId必填");
if (req.safeMin != null && req.safeMax != null) {
if (req.safeMin.compareTo(req.safeMax) > 0) throw new IllegalArgumentException("安全库存区间不合法");
}
if (req.barcode != null && !req.barcode.isBlank()) {
if (productRepository.existsByShopIdAndBarcode(shopId, req.barcode)) {
// 更新时允许自己相同由Controller层在调用前判定并跳过简化此处逻辑
}
}
}
private void upsertPrice(Long userId, LocalDateTime now, Long productId, Long shopId, ProductDtos.Prices prices) {
if (prices == null) prices = new ProductDtos.Prices();
java.util.Optional<ProductPrice> existed = priceRepository.findById(productId);
ProductPrice pr = existed.orElseGet(ProductPrice::new);
pr.setProductId(productId);
pr.setShopId(shopId);
pr.setUserId(userId);
pr.setPurchasePrice(nvl(prices.purchasePrice, BigDecimal.ZERO));
pr.setRetailPrice(nvl(prices.retailPrice, BigDecimal.ZERO));
// 前端不再传分销价:仅当入参提供时更新;新建记录若未提供则置 0
if (prices.distributionPrice != null) {
pr.setDistributionPrice(prices.distributionPrice);
} else if (existed.isEmpty()) {
pr.setDistributionPrice(BigDecimal.ZERO);
}
pr.setWholesalePrice(nvl(prices.wholesalePrice, BigDecimal.ZERO));
pr.setBigClientPrice(nvl(prices.bigClientPrice, BigDecimal.ZERO));
pr.setUpdatedAt(now);
priceRepository.save(pr);
}
private void upsertInventory(Long userId, LocalDateTime now, Long productId, Long shopId, BigDecimal stock) {
Inventory inv = inventoryRepository.findById(productId).orElseGet(Inventory::new);
inv.setProductId(productId);
inv.setShopId(shopId);
inv.setUserId(userId);
inv.setQuantity(nvl(stock, BigDecimal.ZERO));
inv.setUpdatedAt(now);
inventoryRepository.save(inv);
}
private void syncImages(Long userId, Long productId, Long shopId, java.util.List<String> images) {
imageRepository.deleteByProductId(productId);
if (images == null) return;
int idx = 0;
for (String url : images) {
if (url == null || url.isBlank()) continue;
ProductImage img = new ProductImage();
img.setShopId(shopId);
img.setUserId(userId);
img.setProductId(productId);
img.setUrl(url);
img.setSortOrder(idx++);
imageRepository.save(img);
}
}
private static <T> T nvl(T v, T def) { return v != null ? v : def; }
private static String emptyToNull(String s) { return (s == null || s.isBlank()) ? null : s; }
}

View File

@@ -1 +1,35 @@
spring.application.name=demo
# 数据源配置(通过环境变量注入,避免硬编码)
spring.datasource.url=${SPRING_DATASOURCE_URL}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
# JPA 基本配置
spring.jpa.hibernate.ddl-auto=none
spring.jpa.open-in-view=false
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
# CORS 简单放开(如需跨域)
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:*}
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
spring.web.cors.allowed-headers=*
# Hikari 连接池保活(避免云数据库空闲断开)
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=2
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=600000
spring.datasource.hikari.keepalive-time=300000
spring.datasource.hikari.connection-timeout=30000
# 附件占位图配置(使用环境变量注入路径)
# WINDOWS 示例: setx ATTACHMENTS_PLACEHOLDER_IMAGE "C:\\Users\\21826\\Desktop\\Wj\\PartsInquiry\\backend\\picture\\屏幕截图 2025-08-14 134657.png"
# LINUX/Mac 示例: export ATTACHMENTS_PLACEHOLDER_IMAGE=/path/to/placeholder.png
attachments.placeholder.image-path=${ATTACHMENTS_PLACEHOLDER_IMAGE}
attachments.placeholder.url-path=${ATTACHMENTS_PLACEHOLDER_URL:/api/attachments/placeholder}
# 应用默认上下文(用于开发/演示环境)
app.defaults.shop-id=${APP_DEFAULT_SHOP_ID:1}
app.defaults.user-id=${APP_DEFAULT_USER_ID:2}

View File

@@ -0,0 +1,408 @@
## partsinquiry 数据库文档
更新日期2025-09-16已插入演示数据
说明:本文件根据远程库 mysql.tonaspace.com 中 `partsinquiry` 的实际结构生成,字段/索引/外键信息以线上为准。
### shops
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | 店铺/租户ID |
| name | VARCHAR(100) | NOT NULL | | 店铺名称 |
| status | TINYINT UNSIGNED | NOT NULL | 1 | 状态1启用 0停用 |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_shops_status` (`status`)
### users
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | 用户ID |
| shop_id | BIGINT UNSIGNED | NOT NULL | | 所属店铺 |
| phone | VARCHAR(32) | YES | | 手机号 |
| name | VARCHAR(64) | NOT NULL | | 姓名 |
| role | VARCHAR(32) | NOT NULL | staff | 角色owner/staff/finance/... |
| password_hash | VARCHAR(255) | YES | | 密码哈希(若采用短信登录可为空) |
| status | TINYINT UNSIGNED | NOT NULL | 1 | 状态1启用 0停用 |
| is_owner | TINYINT(1) | NOT NULL | 0 | 是否店主 |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_users_shop` (`shop_id`) - UNIQUE: `ux_users_shop_phone` (`shop_id`,`phone`)
**Foreign Keys**: - `fk_users_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
### user_identities
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| provider | ENUM('wechat_mp','wechat_app') | NOT NULL | | 身份提供方:小程序/APP |
| openid | VARCHAR(64) | NOT NULL | | |
| unionid | VARCHAR(64) | YES | | |
| nickname | VARCHAR(64) | YES | | |
| avatar_url | VARCHAR(512) | YES | | |
| last_login_at | DATETIME | YES | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_identity_shop` (`shop_id`) - KEY: `idx_identity_user` (`user_id`) - UNIQUE: `ux_identity_provider_openid` (`provider`,`openid`) - UNIQUE: `ux_identity_unionid` (`unionid`)
**Foreign Keys**: - `fk_identity_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_identity_user`: `user_id``users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
### wechat_sessions
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| provider | ENUM('wechat_mp','wechat_app') | NOT NULL | | |
| openid | VARCHAR(64) | NOT NULL | | |
| session_key | VARCHAR(128) | NOT NULL | | |
| expires_at | DATETIME | NOT NULL | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_wechat_session_expires` (`expires_at`) - UNIQUE: `ux_wechat_session` (`provider`,`openid`)
### system_parameters
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | 创建/最后修改人 |
| key | VARCHAR(64) | NOT NULL | | 参数键 |
| value | JSON | NOT NULL | | 参数值JSON |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_sysparams_shop` (`shop_id`) - UNIQUE: `ux_sysparams_shop_key` (`shop_id`,`key`)
**Foreign Keys**: - `fk_sysparams_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_sysparams_user`: `user_id``users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
### product_units
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| name | VARCHAR(16) | NOT NULL | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_units_shop` (`shop_id`) - UNIQUE: `ux_units_shop_name` (`shop_id`,`name`)
**Foreign Keys**: - `fk_units_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_units_user`: `user_id``users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
### global_skus
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| name | VARCHAR(120) | NOT NULL | | SKU名称 |
| brand | VARCHAR(64) | YES | | |
| model | VARCHAR(64) | YES | | |
| spec | VARCHAR(128) | YES | | |
| barcode | VARCHAR(32) | YES | | |
| unit_id | BIGINT UNSIGNED | YES | | |
| tags | JSON | YES | | |
| status | ENUM('published','offline') | NOT NULL | published | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_global_skus_brand_model` (`brand`,`model`) - UNIQUE: `ux_global_skus_barcode` (`barcode`)
**Foreign Keys**: - `fk_globalsku_unit`: `unit_id``product_units(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
### product_categories
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| name | VARCHAR(64) | NOT NULL | | |
| parent_id | BIGINT UNSIGNED | YES | | |
| sort_order | INT | NOT NULL | 0 | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_categories_shop` (`shop_id`) - KEY: `idx_categories_parent` (`parent_id`) - UNIQUE: `ux_categories_shop_name` (`shop_id`,`name`)
**Foreign Keys**: - `fk_categories_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_categories_user`: `user_id``users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_categories_parent`: `parent_id``product_categories(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
### products
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| name | VARCHAR(120) | NOT NULL | | 供全文检索 |
| category_id | BIGINT UNSIGNED | YES | | |
| unit_id | BIGINT UNSIGNED | NOT NULL | | |
| brand | VARCHAR(64) | YES | | |
| model | VARCHAR(64) | YES | | |
| spec | VARCHAR(128) | YES | | |
| origin | VARCHAR(64) | YES | | |
| barcode | VARCHAR(32) | YES | | |
| alias | VARCHAR(120) | YES | | |
| description | TEXT | YES | | |
| global_sku_id | BIGINT UNSIGNED | YES | | |
| safe_min | DECIMAL(18,3) | YES | | |
| safe_max | DECIMAL(18,3) | YES | | |
| search_text | TEXT | YES | | 供全文检索的聚合字段(名称/品牌/型号/规格/别名) |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_products_shop` (`shop_id`) - KEY: `idx_products_category` (`category_id`) - KEY: `idx_products_unit` (`unit_id`) - FULLTEXT: `ft_products_search` (`name`,`brand`,`model`,`spec`,`search_text`) - UNIQUE: `ux_products_shop_barcode` (`shop_id`,`barcode`)
**Foreign Keys**: - `fk_products_shop`: `shop_id``shops(id)` - `fk_products_user`: `user_id``users(id)` - `fk_products_category`: `category_id``product_categories(id)` - `fk_products_unit`: `unit_id``product_units(id)` - `fk_products_globalsku`: `global_sku_id``global_skus(id)`
### product_aliases
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| product_id | BIGINT UNSIGNED | NOT NULL | | |
| alias | VARCHAR(120) | NOT NULL | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_product_alias_product` (`product_id`) - UNIQUE: `ux_product_alias` (`product_id`,`alias`)
**Foreign Keys**: - `fk_alias_shop`: `shop_id``shops(id)` - `fk_alias_user`: `user_id``users(id)` - `fk_alias_product`: `product_id``products(id)`
### product_images
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| product_id | BIGINT UNSIGNED | NOT NULL | | |
| url | VARCHAR(512) | NOT NULL | | |
| hash | VARCHAR(64) | YES | | 内容哈希(去重) |
| sort_order | INT | NOT NULL | 0 | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_product_images_product` (`product_id`) - UNIQUE: `ux_product_image_hash` (`product_id`,`hash`)
**Foreign Keys**: - `fk_pimg_shop`: `shop_id``shops(id)` - `fk_pimg_user`: `user_id``users(id)` - `fk_pimg_product`: `product_id``products(id)` ON DELETE CASCADE
### product_prices
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| product_id | BIGINT UNSIGNED | NOT NULL | | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| purchase_price | DECIMAL(18,2) | NOT NULL | 0.00 | |
| retail_price | DECIMAL(18,2) | NOT NULL | 0.00 | |
| distribution_price | DECIMAL(18,2) | NOT NULL | 0.00 | |
| wholesale_price | DECIMAL(18,2) | NOT NULL | 0.00 | |
| big_client_price | DECIMAL(18,2) | NOT NULL | 0.00 | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
**Indexes**: - PRIMARY KEY: `product_id` - KEY: `idx_prices_shop` (`shop_id`)
**Foreign Keys**: - `fk_prices_product`: `product_id``products(id)` ON DELETE CASCADE - `fk_prices_shop`: `shop_id``shops(id)` - `fk_prices_user`: `user_id``users(id)`
### inventories
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| product_id | BIGINT UNSIGNED | NOT NULL | | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| quantity | DECIMAL(18,3) | NOT NULL | 0.000 | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
**Indexes**: - PRIMARY KEY: `product_id` - KEY: `idx_inventories_shop` (`shop_id`)
**Foreign Keys**: - `fk_inv_product`: `product_id``products(id)` ON DELETE CASCADE - `fk_inv_shop`: `shop_id``shops(id)` - `fk_inv_user`: `user_id``users(id)`
### customers
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| name | VARCHAR(120) | NOT NULL | | |
| phone | VARCHAR(32) | YES | | |
| level | VARCHAR(32) | YES | | 客户等级标签 |
| price_level | ENUM('retail','distribution','wholesale','big_client') | NOT NULL | retail | 默认售价列 |
| status | TINYINT UNSIGNED | NOT NULL | 1 | |
| remark | VARCHAR(255) | YES | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_customers_shop` (`shop_id`) - KEY: `idx_customers_phone` (`phone`)
**Foreign Keys**: - `fk_customers_shop`: `shop_id``shops(id)` - `fk_customers_user`: `user_id``users(id)`
### suppliers
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| name | VARCHAR(120) | NOT NULL | | |
| phone | VARCHAR(32) | YES | | |
| status | TINYINT UNSIGNED | NOT NULL | 1 | |
| remark | VARCHAR(255) | YES | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_suppliers_shop` (`shop_id`) - KEY: `idx_suppliers_phone` (`phone`)
**Foreign Keys**: - `fk_suppliers_shop`: `shop_id``shops(id)` - `fk_suppliers_user`: `user_id``users(id)`
### accounts
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| name | VARCHAR(64) | NOT NULL | | |
| type | ENUM('cash','bank','alipay','wechat','other') | NOT NULL | cash | |
| balance | DECIMAL(18,2) | NOT NULL | 0.00 | |
| status | TINYINT UNSIGNED | NOT NULL | 1 | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_accounts_shop` (`shop_id`) - UNIQUE: `ux_accounts_shop_name` (`shop_id`,`name`)
**Foreign Keys**: - `fk_accounts_shop`: `shop_id``shops(id)` - `fk_accounts_user`: `user_id``users(id)`
### sales_orders
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | 创建人 |
| customer_id | BIGINT UNSIGNED | YES | | |
| order_no | VARCHAR(32) | NOT NULL | | |
| order_time | DATETIME | NOT NULL | | |
| status | ENUM('draft','approved','returned','void') | NOT NULL | draft | |
| amount | DECIMAL(18,2) | NOT NULL | 0.00 | 应收合计 |
| paid_amount | DECIMAL(18,2) | NOT NULL | 0.00 | 已收合计 |
| remark | VARCHAR(255) | YES | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_sales_shop_time` (`shop_id`,`order_time`) - KEY: `idx_sales_customer` (`customer_id`) - UNIQUE: `ux_sales_order_no` (`shop_id`,`order_no`)
**Foreign Keys**: - `fk_sales_shop`: `shop_id``shops(id)` - `fk_sales_user`: `user_id``users(id)` - `fk_sales_customer`: `customer_id``customers(id)`
### sales_order_items
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| order_id | BIGINT UNSIGNED | NOT NULL | | |
| product_id | BIGINT UNSIGNED | NOT NULL | | |
| quantity | DECIMAL(18,3) | NOT NULL | | |
| unit_price | DECIMAL(18,2) | NOT NULL | | |
| discount_rate | DECIMAL(5,2) | NOT NULL | 0.00 | 折扣百分比0-100 |
| amount | DECIMAL(18,2) | NOT NULL | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_soi_order` (`order_id`) - KEY: `idx_soi_product` (`product_id`)
**Foreign Keys**: - `fk_soi_order`: `order_id``sales_orders(id)` ON DELETE CASCADE - `fk_soi_product`: `product_id``products(id)`
### purchase_orders
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| supplier_id | BIGINT UNSIGNED | YES | | |
| order_no | VARCHAR(32) | NOT NULL | | |
| order_time | DATETIME | NOT NULL | | |
| status | ENUM('draft','approved','void') | NOT NULL | draft | |
| amount | DECIMAL(18,2) | NOT NULL | 0.00 | 应付合计 |
| paid_amount | DECIMAL(18,2) | NOT NULL | 0.00 | 已付合计 |
| remark | VARCHAR(255) | YES | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_purchase_shop_time` (`shop_id`,`order_time`) - KEY: `idx_purchase_supplier` (`supplier_id`) - UNIQUE: `ux_purchase_order_no` (`shop_id`,`order_no`)
**Foreign Keys**: - `fk_purchase_shop`: `shop_id``shops(id)` - `fk_purchase_user`: `user_id``users(id)` - `fk_purchase_supplier`: `supplier_id``suppliers(id)`
### purchase_order_items
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| order_id | BIGINT UNSIGNED | NOT NULL | | |
| product_id | BIGINT UNSIGNED | NOT NULL | | |
| quantity | DECIMAL(18,3) | NOT NULL | | |
| unit_price | DECIMAL(18,2) | NOT NULL | | |
| amount | DECIMAL(18,2) | NOT NULL | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_poi_order` (`order_id`) - KEY: `idx_poi_product` (`product_id`)
**Foreign Keys**: - `fk_poi_order`: `order_id``purchase_orders(id)` ON DELETE CASCADE - `fk_poi_product`: `product_id``products(id)`
### payments
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| biz_type | ENUM('sale','purchase','other') | NOT NULL | | |
| biz_id | BIGINT UNSIGNED | YES | | 业务表IDsales_orders/purchase_orders/other_transactions |
| account_id | BIGINT UNSIGNED | NOT NULL | | |
| direction | ENUM('in','out') | NOT NULL | | 收款/付款 |
| amount | DECIMAL(18,2) | NOT NULL | | |
| pay_time | DATETIME | NOT NULL | | |
| remark | VARCHAR(255) | YES | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_payments_shop_time` (`shop_id`,`pay_time`) - KEY: `idx_payments_biz` (`biz_type`,`biz_id`)
**Foreign Keys**: - `fk_payments_shop`: `shop_id``shops(id)` - `fk_payments_user`: `user_id``users(id)` - `fk_payments_account`: `account_id``accounts(id)`
### other_transactions
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| type | ENUM('income','expense') | NOT NULL | | |
| category | VARCHAR(64) | NOT NULL | | |
| counterparty_type | VARCHAR(32) | YES | | customer/supplier/other |
| counterparty_id | BIGINT UNSIGNED | YES | | |
| account_id | BIGINT UNSIGNED | NOT NULL | | |
| amount | DECIMAL(18,2) | NOT NULL | | |
| tx_time | DATETIME | NOT NULL | | |
| remark | VARCHAR(255) | YES | | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_ot_shop_time` (`shop_id`,`tx_time`) - KEY: `idx_ot_account` (`account_id`)
**Foreign Keys**: - `fk_ot_shop`: `shop_id``shops(id)` - `fk_ot_user`: `user_id``users(id)` - `fk_ot_account`: `account_id``accounts(id)`
### 触发器
- `trg_products_bi`: BEFORE INSERT ON `products` → 设置 `products.search_text`
- `trg_products_au`: BEFORE UPDATE ON `products` → 维护 `products.search_text`
- `trg_palias_ai`: AFTER INSERT ON `product_aliases` → 重建 `products.search_text`
- `trg_palias_au`: AFTER UPDATE ON `product_aliases` → 重建 `products.search_text`
- `trg_palias_ad`: AFTER DELETE ON `product_aliases` → 重建 `products.search_text`
### notices
| Column Name | Data Type | Nullable | Default | Comment |
| ----------- | --------- | -------- | ------- | ------- |
| id | BIGINT UNSIGNED | NOT NULL | AUTO_INCREMENT | |
| shop_id | BIGINT UNSIGNED | NOT NULL | | |
| user_id | BIGINT UNSIGNED | NOT NULL | | |
| title | VARCHAR(120) | NOT NULL | | |
| content | VARCHAR(500) | NOT NULL | | |
| tag | VARCHAR(32) | YES | | |
| is_pinned | TINYINT(1) | NOT NULL | 0 | |
| starts_at | DATETIME | YES | | |
| ends_at | DATETIME | YES | | |
| status | ENUM('draft','published','offline') | NOT NULL | published | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| updated_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |
| deleted_at | DATETIME | YES | | |
**Indexes**: - PRIMARY KEY: `id` - KEY: `idx_notices_shop` (`shop_id`,`status`,`is_pinned`,`created_at`) - KEY: `idx_notices_time` (`starts_at`,`ends_at`)
**Foreign Keys**: - `fk_notices_shop`: `shop_id``shops(id)` ON UPDATE NO ACTION ON DELETE NO ACTION - `fk_notices_user`: `user_id``users(id)` ON UPDATE NO ACTION ON DELETE NO ACTION
### 附:演示种子数据(非完整,仅用于联调验证)
- 演示店铺演示店A用户 3全部店长 owner
- 商品域基础单位3条、类别2条、全局SKU2条、商品2条含别名/价格/库存/图片)
- 往来与账户客户2、供应商2、账户3
- 单据销售单1含明细2与进货单1含明细2、收付款各1、其他收支2
- 审核与公告part_submissions 1、attachments 1、notices 2、新增 wechat 身份与会话各1

658
doc/openapi.yaml Normal file
View File

@@ -0,0 +1,658 @@
openapi: 3.0.3
info:
title: PartsInquiry API
version: 0.1.0
description: >-
所有接口定义集中于此文件。每个 path 在 summary/description 中标注实现状态。
servers:
- url: /
paths:
/api/dashboard/overview:
get:
summary: 首页概览(✅ Fully Implemented
description: 订单口径的今日销售额approved、近似本月毛利按当前进价近似与库存总量。支持 X-Shop-Id 或 X-User-Id优先从用户解析店铺。后端与前端均已接入。
parameters:
- in: header
name: X-Shop-Id
required: false
schema:
type: integer
description: 店铺ID缺省为 1
- in: header
name: X-User-Id
required: false
schema:
type: integer
description: 用户ID当未提供 X-Shop-Id 时将用其所属店铺进行统计
responses:
'200':
description: 成功
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardOverview'
/api/notices:
get:
summary: 公告列表(✅ Fully Implemented
description: 返回当前店铺可见的公告列表。后端与前端均已接入。
parameters:
- in: header
name: X-Shop-Id
required: false
schema:
type: integer
description: 店铺ID缺省为 1
responses:
'200':
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Notice'
/api/metrics/overview:
get:
summary: 概览统计(❌ Partially Implemented
description: 返回今日/本月销售额、本月利润与库存商品数量。前端已接入,后端待实现。
responses:
'200':
description: 成功
content:
application/json:
schema:
$ref: '#/components/schemas/MetricsOverview'
/api/accounts:
get:
summary: 账户列表(❌ Partially Implemented
description: 前端账户选择页已接入,后端返回数组或 {list:[]} 皆可。
responses:
'200':
description: 成功
content:
application/json:
schema:
oneOf:
- type: array
items:
$ref: '#/components/schemas/Account'
- type: object
properties:
list:
type: array
items:
$ref: '#/components/schemas/Account'
/api/suppliers:
get:
summary: 供应商搜索(❌ Partially Implemented
parameters:
- in: query
name: kw
schema:
type: string
responses:
'200':
description: 成功
content:
application/json:
schema:
oneOf:
- type: array
items:
$ref: '#/components/schemas/Supplier'
- type: object
properties:
list:
type: array
items:
$ref: '#/components/schemas/Supplier'
/api/other-transactions:
post:
summary: 新建其他收入/支出(❌ Partially Implemented
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOtherTransactionRequest'
responses:
'200':
description: 成功
content:
application/json:
schema:
type: object
properties:
id:
type: integer
format: int64
/api/products:
get:
summary: 商品搜索(✅ Fully Implemented
description: 支持 kw/page/size/categoryId返回 {list:[]} 以兼容前端。
parameters:
- in: query
name: kw
schema:
type: string
- in: query
name: page
schema:
type: integer
default: 1
- in: query
name: size
schema:
type: integer
default: 50
responses:
'200':
description: 成功
content:
application/json:
schema:
oneOf:
- type: array
items:
$ref: '#/components/schemas/Product'
post:
summary: 新建商品(✅ Fully Implemented
description: 保存商品、价格、库存与图片(当前图片统一占位图 URL
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateProductRequest'
responses:
'200':
description: 成功
content:
application/json:
schema:
type: object
properties:
id:
type: integer
format: int64
/api/products/{id}:
get:
summary: 商品详情(✅ Fully Implemented
parameters:
- in: path
name: id
required: true
schema: { type: integer }
responses:
'200':
description: 成功
content:
application/json:
schema:
$ref: '#/components/schemas/ProductDetail'
put:
summary: 更新商品(✅ Fully Implemented
parameters:
- in: path
name: id
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateProductRequest'
responses:
'200':
description: 成功
/api/product-categories:
get:
summary: 类别列表(✅ Fully Implemented
responses:
'200':
description: 成功
content:
application/json:
schema:
oneOf:
- type: array
items: { $ref: '#/components/schemas/Category' }
- type: object
properties:
list:
type: array
items: { $ref: '#/components/schemas/Category' }
post:
summary: 新增类别(❌ Partially Implemented
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
/api/product-categories/{id}:
put:
summary: 更新类别(❌ Partially Implemented
parameters:
- in: path
name: id
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
delete:
summary: 删除类别(❌ Partially Implemented
parameters:
- in: path
name: id
required: true
schema: { type: integer }
responses: { '200': { description: 成功 } }
/api/product-units:
get:
summary: 单位列表(✅ Fully Implemented
responses:
'200':
description: 成功
content:
application/json:
schema:
oneOf:
- type: array
items: { $ref: '#/components/schemas/Unit' }
- type: object
properties:
list:
type: array
items: { $ref: '#/components/schemas/Unit' }
post:
summary: 新增单位(❌ Partially Implemented
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
/api/product-units/{id}:
put:
summary: 更新单位(❌ Partially Implemented
parameters:
- in: path
name: id
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
delete:
summary: 删除单位(❌ Partially Implemented
parameters:
- in: path
name: id
required: true
schema: { type: integer }
responses: { '200': { description: 成功 } }
/api/product-settings:
get:
summary: 货品设置读取(❌ Partially Implemented
responses:
'200':
description: 成功
content:
application/json:
schema:
$ref: '#/components/schemas/ProductSettings'
put:
summary: 货品设置保存(❌ Partially Implemented
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ProductSettings'
responses: { '200': { description: 成功 } }
- type: object
properties:
list:
type: array
items:
$ref: '#/components/schemas/Product'
/api/customers:
get:
summary: 客户搜索(❌ Partially Implemented
description: 前端已接入查询参数 kw/page/size后端待实现或对齐。
parameters:
- in: query
name: kw
schema:
type: string
- in: query
name: page
schema:
type: integer
default: 1
- in: query
name: size
schema:
type: integer
default: 50
responses:
'200':
description: 成功
content:
application/json:
schema:
oneOf:
- type: array
items:
$ref: '#/components/schemas/Customer'
- type: object
properties:
list:
type: array
items:
$ref: '#/components/schemas/Customer'
/api/orders:
post:
summary: 新建单据(❌ Partially Implemented
description: 前端开单页已提交 payload后端待实现。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'200':
description: 成功
content:
application/json:
schema:
type: object
properties:
id:
type: integer
format: int64
orderNo:
type: string
/api/attachments:
post:
summary: 上传附件(✅ Fully Implemented占位图方案
description: 接收 multipart 上传但忽略文件内容,始终返回占位图 URL后端配置项 `attachments.placeholder.image-path` 指向本地占位图片URL 固定 `/api/attachments/placeholder` 可通过 `attachments.placeholder.url-path` 覆盖)。
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
ownerType:
type: string
ownerId:
type: string
responses:
'200':
description: 成功
content:
application/json:
schema:
type: object
properties:
url:
type: string
/api/attachments/placeholder:
get:
summary: 附件占位图读取(✅ Fully Implemented
description: 返回后端配置的本地占位图内容,路径由 `attachments.placeholder.image-path` 指定。
responses:
'200':
description: 图片二进制
content:
image/png:
schema:
type: string
format: binary
components:
schemas:
DashboardOverview:
type: object
properties:
todaySalesAmount:
type: number
example: 1250.00
monthSalesAmount:
type: number
example: 26500.00
monthGrossProfit:
type: number
example: 3560.25
stockTotalQuantity:
type: number
example: 1300
Notice:
type: object
properties:
id:
type: integer
format: int64
shopId:
type: integer
format: int64
userId:
type: integer
format: int64
title:
type: string
content:
type: string
tag:
type: string
pinned:
type: boolean
startsAt:
type: string
format: date-time
MetricsOverview:
type: object
properties:
todaySales:
type: string
example: '1234.56'
monthSales:
type: string
example: '23456.78'
monthProfit:
type: string
example: '3456.78'
stockCount:
type: string
example: '1200'
Account:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
type:
type: string
enum: [cash, bank, alipay, wechat, other]
balance:
type: number
Supplier:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
mobile:
type: string
CreateOtherTransactionRequest:
type: object
properties:
type:
type: string
enum: [income, expense]
category:
type: string
counterpartyId:
type: integer
format: int64
nullable: true
accountId:
type: integer
format: int64
amount:
type: number
txTime:
type: string
format: date
remark:
type: string
endsAt:
type: string
format: date-time
status:
type: string
enum: [DRAFT, PUBLISHED, OFFLINE]
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
Product:
type: object
properties:
id:
type: integer
format: int64
code:
type: string
name:
type: string
price:
type: number
stock:
type: number
ProductDetail:
allOf:
- $ref: '#/components/schemas/Product'
- type: object
properties:
brand: { type: string }
model: { type: string }
spec: { type: string }
categoryId: { type: integer, format: int64, nullable: true }
unitId: { type: integer, format: int64 }
safeMin: { type: number, nullable: true }
safeMax: { type: number, nullable: true }
purchasePrice: { type: number }
retailPrice: { type: number }
distributionPrice: { type: number }
wholesalePrice: { type: number }
bigClientPrice: { type: number }
images:
type: array
items:
type: object
properties: { url: { type: string } }
CreateProductRequest:
type: object
properties:
name: { type: string }
barcode: { type: string, nullable: true }
brand: { type: string, nullable: true }
model: { type: string, nullable: true }
spec: { type: string, nullable: true }
categoryId: { type: integer, format: int64, nullable: true }
unitId: { type: integer, format: int64 }
safeMin: { type: number, nullable: true }
safeMax: { type: number, nullable: true }
prices:
type: object
properties:
purchasePrice: { type: number }
retailPrice: { type: number }
distributionPrice: { type: number }
wholesalePrice: { type: number }
bigClientPrice: { type: number }
stock: { type: number, nullable: true }
images:
type: array
items: { type: string, description: '图片URL' }
Category:
type: object
properties:
id: { type: integer, format: int64 }
name: { type: string }
Unit:
type: object
properties:
id: { type: integer, format: int64 }
name: { type: string }
ProductSettings:
type: object
properties:
hideZeroStock: { type: boolean }
hidePurchasePrice: { type: boolean }
Customer:
type: object
properties:
id:
type: integer
format: int64
name:
type: string
mobile:
type: string
CreateOrderRequest:
type: object
properties:
type:
type: string
description: 'sale.out/sale.return/sale.collect/purchase/income/expense 等'
orderTime:
type: string
format: date
customerId:
type: integer
format: int64
nullable: true
items:
type: array
items:
type: object
properties:
productId:
type: integer
format: int64
quantity:
type: number
unitPrice:
type: number
amount:
type: number

167
doc/requirements.md Normal file
View File

@@ -0,0 +1,167 @@
* ### **配件查询App需求规格说明书**
#### 1.0 项目概述
本项目旨在开发一款面向小微商户的移动端进销存管理应用,命名为“配件查询”。该应用核心功能是帮助用户高效管理商品、库存、销售、采购、客户、供应商及财务收支,并通过数据报表提供经营状况分析,助力商户实现数字化经营。
参考的小程序“智慧记进销存”,但是多了一个配件查询功能,以下所罗列的内容大多也参考至该小程序,如有歧义可优先参照这个小程序,拿不准优先问。
#### 2.0 功能模块需求
**2.1 首页 (Dashboard)**
* **2.1.1 核心数据概览:** 首页需直观展示当日、当月的核心经营数据。
* 今日销售额
* 本月销售额
* 本月利润
* 库存商品数量
**2.1.2 广告位:** 在首页区域提供一个展示广告的区域。
* **2.1.3 快捷功能入口:** 提供一个快捷功能区域,方便用户快速访问常用功能。
* 默认应包含客户管理、销售开单、账户管理、供应商管理、进货开单、其他支出、VIP会员、报表统计等。
* **2.1.4 在线客服:** 提供一个悬浮的“咨询”或“在线客服”入口,方便用户随时获取帮助。
**2.2 货品管理模块**
* **2.2.1 货品列表与库存:**
* 展示所有货品的列表,包含名称、库存等基本信息。
* 支持按“全部类别”或指定类别筛选货品。
* 提供搜索功能,支持通过货品名称或条形码进行模糊查找。
* 列表底部显示总货品种类数量。
* 当库存为空时,应有明显的空状态提示,并引导用户“点击右上角‘+’录入货品信息”。
* **2.2.2 新增/编辑货品:**
* 支持添加商品图片。
* App端支持录入或扫描商品条形码小程序端仅支持手动录入不支持扫码。
* **货品名称**为必填项。
* 可为货品选择**类别**和**主单位**。
* 支持自定义扩展货品属性(如品牌、型号、产地、保质期等)。
* 货品图片支持多图上传,支持拖拽排序,支持图片放大预览。
* 可录入**当前库存**、**安全库存**(一个数值区间,用于库存预警)。
* 需分别录入四种价格,**进货价**、**批发价**、**大单报价**和**零售价**。
* 提供**备注**字段,用于记录额外信息。
* 保存后,可选择“继续新增”或返回列表。
* **2.2.3 货品设置:**
* 支持自定义**货品类别**管理。
* 支持自定义**货品单位**管理。
* 提供开关选项,允许用户选择是否“隐藏零库存商品”和“隐藏进货价”。
**2.3 开单(交易)模块**
* **2.3.1 核心功能:** 该模块是应用的核心操作区,整合了销售、进货和财务记账功能。
* **2.3.2 销售开单:**
* **出货单:**
* 自动记录开单**时间**,并支持手动修改。
* 可选择**客户**,默认为“零售客户”。
* 通过“+”号从货品列表中选择商品,自动计算**合计金额**。
* 支持在订单中对单个商品进行操作(如修改数量、价格等)。
* **退货单:** 用于处理客户退货业务。
* **收款单:** 用于处理销售单的后续收款或直接创建收款记录。
* **2.3.3 进货开单:** 用于记录从供应商处采购商品的业务流程。
* **2.3.4 其他收入/支出:**
* **其他收入:**
* 支持对收入进行分类,如“销售收入”、“经营所得”、“利息收入”等。
* 可选择**往来单位**和**结算账户**(如现金、银行等)。
* 可添加备注并选择日期。
* **其他支出:**
* 支持对支出进行分类,如“经营支出”、“办公用品”、“房租”等。
* 同样支持选择**往来单位**和**结算账户**。
**2.4 明细查询模块**
* **2.4.1 维度筛选:**
* 提供按时间维度(自定义、本周、今日、本月、本年)快速筛选单据。
* 提供按业务类型(销售、进货、收银、资金、盘点)进行分类查看。
* **2.4.2 单据列表:**
* 在选定维度下,以列表形式展示所有相关单据。
* 提供搜索功能,支持通过单据号、客户/供应商名、品名、备注等关键字查询。
* 显示当前筛选条件下的总金额。
* 当无数据时,提供清晰的空状态提示。
* 提供“+”号,支持在当前分类下快速新建单据。
**2.5 报表统计模块**
* **2.5.1 资金报表:**
* **利润统计:** 分析指定时间范围内的收入、支出和利润。
* **营业员统计:** 按销售人员维度统计销售业绩。
* **经营业绩:** 提供综合性的经营状况分析。
* **导入导出模块:** 提供导入导出功能方便用户切换手机或账号后仍能将旧数据导入。
* **2.5.2 进销存报表:**
* **销售统计:** 按商品、客户、时间等维度分析销售数据。
* **进货统计:** 按商品、供应商、时间等维度分析采购数据。
* **库存统计:** 提供当前库存成本、数量及分布情况的报告。
* **应收/应付对账单:** 生成与客户和供应商的对账单据。
**2.6 “我的”(用户中心)模块**
* **2.6.1 用户信息:** 显示用户头像、店铺名称、注册手机号及“老板”身份标识。
* **2.6.2 会员与订单:**
* 提供**VIP会员**入口,展示会员特权。
* 提供**我的订单**入口,可能用于查看应用内服务订单。
* **2.6.3 基础管理:**
* **供应商管理**
* **客户管理**
* **客户报价**
* **店铺管理**
* **2.6.4 设置中心:**
* **账号与安全:**
* 修改个人信息(头像、姓名)。
* 修改登录密码。
* **商品设置:**
* **系统参数:**
* 提供多种业务逻辑开关,如:“销售价低于进货价时提示”、“销售单默认全部收款”、“启用单行折扣”、“启用客户/供应商双身份”。
* **关于与协议:** 包含“关于我们”、“隐私协议”、“个人信息安全投诉”等静态页面。
* **账号操作:** 提供“账号注销”和“退出登录”功能。
#### 3.0 全局性需求
* **3.1 导航:** 采用底部Tab栏导航包含“首页”、“货品”、“开单”、“明细”、“我的”五个主要模块。
* **3.2 统一的UI/UX:** 应用整体风格简洁、清晰,操作流程符合移动端使用习惯。
* **3.3 空状态页面:** 在列表、报表等数据为空的页面,需提供友好的空状态提示图和引导性文字。
* **3.4 数据同步:** 应用数据应在云端同步,保证用户更换设备或多设备使用时数据一致性。
* **3.5 多租户数据隔离:** 所有业务数据按店铺(租户)隔离,用户不可访问他人数据。
* 所有业务表需包含`user_id`并在读取/写入中强制按`user_id`过滤。
* 支持租户内角色与权限控制;导出仅限本租户数据。
* **3.6 公共SKU全局商品库众包与审核:** 全体用户共同补充、纠错SKU经审核发布为全局可选SKU。
* 用户可提交“新增SKU/编辑建议”,进入审核流(草稿/待审/驳回/发布/下架)。
* 全局SKU字段名称、品牌、规格、条码、主单位、图片、别名、分类标签等。
* 各用户通过“本地商品”引用全局SKU并保留本地私有字段价格、库存、分类、单位换算、条码别名等
* **3.7 商品模糊查询(增强):** 在货品列表、开单选品、对账等场景支持多字段模糊匹配。
* 支持名称/条码/别名/拼音/品牌/规格模糊匹配,并高亮命中片段。
* 支持全局SKU与本地商品联合检索优先展示本地商品结果可分页。
* 需满足大规模SKU下的性能目标可通过系统参数配置匹配策略。
* **3.8 客户端平台:** 提供移动App与小程序小程序不支持商品条形码扫描功能。
* **3.9 多列销售价格:** 销售价格分四列,即同一种商品有四个销售价格
### 配件查询
1. **数据查询功能**
- 多参数组合查询(分类、尺寸、型号等)
- 模糊匹配关键字
- 分页展示查询结果
- 一键导出Excel数据
2. **数据提交系统**
- 用户提交新配件数据
- 型号为唯一必填项
- 支持图片上传
- 提交后等待管理员审核
3. **审核管理系统**
- 管理员查看待审核列表
- 可编辑所有字段
- 支持图片更新和删除
- 一键批准或拒绝提交
4. **图片管理系统**
- 每条数据可关联多张图片
- 点击图片可放大查看
- 管理员可管理所有图片
- 自动处理文件名冲突
## 全局说明(必看)
由于这个文档写的还不是很完善,目前有存疑的部分先行参考小程序小程序“智慧记进销存”(功能和按钮可以参考,界面样式除外),管理端文档目前待定。
客户要求的是做双端应用app端+小程序端),需要考虑兼容性相关问题。
本程序和“智慧记进销存”大多一致,主要的区别在于客户有配件查询要求,即在产品页面中要额外加一个配件查询按钮或入口,且要求一个产品要有四个销售价格(先按零售价 分销价 批发价 大客户价),且要求能自定义添加各种规格(尺寸,孔径等)。
有疑惑的部分一定要及时沟通(如未提及的页面和功能需要确认的时候)

31
frontend/common/config.js Normal file
View File

@@ -0,0 +1,31 @@
// 统一配置:禁止在业务代码中硬编码
// 优先级:环境变量(Vite/HBuilderX 构建注入) > 本地存储 > 默认值
const envBaseUrl = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_API_BASE_URL || process.env.API_BASE_URL)) || '';
const storageBaseUrl = typeof uni !== 'undefined' ? (uni.getStorageSync('API_BASE_URL') || '') : '';
const fallbackBaseUrl = 'http://192.168.31.193:8080';
export const API_BASE_URL = (envBaseUrl || storageBaseUrl || fallbackBaseUrl).replace(/\/$/, '');
// 多地址候选(按优先级顺序,自动去重与去尾斜杠)
const candidateBases = [envBaseUrl, storageBaseUrl, fallbackBaseUrl, 'http://127.0.0.1:8080', 'http://localhost:8080'];
export const API_BASE_URL_CANDIDATES = Array.from(new Set(candidateBases.filter(Boolean))).map(u => String(u).replace(/\/$/, ''));
const envShopId = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_SHOP_ID || process.env.SHOP_ID)) || '';
const storageShopId = typeof uni !== 'undefined' ? (uni.getStorageSync('SHOP_ID') || '') : '';
export const SHOP_ID = Number(envShopId || storageShopId || 1);
// 默认用户(可移除):
// - 用途:开发/演示环境自动将用户固定为“张老板”id=2
// - 开关优先级:环境变量 > 本地存储 > 默认值
// - 生产默认关闭false开发可通过本地存储或环境变量开启
const envEnableDefaultUser = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_ENABLE_DEFAULT_USER || process.env.ENABLE_DEFAULT_USER)) || '';
const storageEnableDefaultUser = typeof uni !== 'undefined' ? (uni.getStorageSync('ENABLE_DEFAULT_USER') || '') : '';
export const ENABLE_DEFAULT_USER = String(envEnableDefaultUser || storageEnableDefaultUser || 'true').toLowerCase() === 'true';
const envDefaultUserId = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_DEFAULT_USER_ID || process.env.DEFAULT_USER_ID)) || '';
const storageDefaultUserId = typeof uni !== 'undefined' ? (uni.getStorageSync('DEFAULT_USER_ID') || '') : '';
export const DEFAULT_USER_ID = Number(envDefaultUserId || storageDefaultUserId || 2);

View File

@@ -0,0 +1,19 @@
// 统一常量配置:其他收入/支出分类,禁止在业务中硬编码
export const INCOME_CATEGORIES = [
{ key: 'sale_income', label: '销售收入' },
{ key: 'operation_income', label: '经营所得' },
{ key: 'interest_income', label: '利息收入' },
{ key: 'investment_income', label: '投资收入' },
{ key: 'other_income', label: '其它收入' }
]
export const EXPENSE_CATEGORIES = [
{ key: 'operation_expense', label: '经营支出' },
{ key: 'office_supplies', label: '办公用品' },
{ key: 'rent', label: '房租' },
{ key: 'interest_expense', label: '利息支出' },
{ key: 'other_expense', label: '其它支出' }
]

96
frontend/common/http.js Normal file
View File

@@ -0,0 +1,96 @@
import { API_BASE_URL, API_BASE_URL_CANDIDATES, SHOP_ID, ENABLE_DEFAULT_USER, DEFAULT_USER_ID } from './config.js'
function buildUrl(path) {
if (!path) return API_BASE_URL
if (path.startsWith('http')) return path
return API_BASE_URL + (path.startsWith('/') ? path : '/' + path)
}
function requestWithFallback(options, candidates, idx, resolve, reject) {
const base = candidates[idx] || API_BASE_URL
const url = options.url.replace(/^https?:\/\/[^/]+/, base)
uni.request({ ...options, url, success: (res) => {
const { statusCode, data } = res
if (statusCode >= 200 && statusCode < 300) return resolve(data)
if (idx + 1 < candidates.length) return requestWithFallback(options, candidates, idx + 1, resolve, reject)
reject(new Error('HTTP ' + statusCode))
}, fail: (err) => {
if (idx + 1 < candidates.length) return requestWithFallback(options, candidates, idx + 1, resolve, reject)
reject(err)
} })
}
export function get(path, params = {}) {
return new Promise((resolve, reject) => {
const headers = { 'X-Shop-Id': SHOP_ID }
if (ENABLE_DEFAULT_USER && DEFAULT_USER_ID) headers['X-User-Id'] = DEFAULT_USER_ID
const options = { url: buildUrl(path), method: 'GET', data: params, header: headers }
requestWithFallback(options, API_BASE_URL_CANDIDATES, 0, resolve, reject)
})
}
export function post(path, body = {}) {
return new Promise((resolve, reject) => {
const headers = { 'Content-Type': 'application/json', 'X-Shop-Id': SHOP_ID }
if (ENABLE_DEFAULT_USER && DEFAULT_USER_ID) headers['X-User-Id'] = DEFAULT_USER_ID
const options = { url: buildUrl(path), method: 'POST', data: body, header: headers }
requestWithFallback(options, API_BASE_URL_CANDIDATES, 0, resolve, reject)
})
}
export function put(path, body = {}) {
return new Promise((resolve, reject) => {
const headers = { 'Content-Type': 'application/json', 'X-Shop-Id': SHOP_ID }
if (ENABLE_DEFAULT_USER && DEFAULT_USER_ID) headers['X-User-Id'] = DEFAULT_USER_ID
const options = { url: buildUrl(path), method: 'PUT', data: body, header: headers }
requestWithFallback(options, API_BASE_URL_CANDIDATES, 0, resolve, reject)
})
}
export function del(path, body = {}) {
return new Promise((resolve, reject) => {
const headers = { 'Content-Type': 'application/json', 'X-Shop-Id': SHOP_ID }
if (ENABLE_DEFAULT_USER && DEFAULT_USER_ID) headers['X-User-Id'] = DEFAULT_USER_ID
const options = { url: buildUrl(path), method: 'DELETE', data: body, header: headers }
requestWithFallback(options, API_BASE_URL_CANDIDATES, 0, resolve, reject)
})
}
function uploadWithFallback(options, candidates, idx, resolve, reject) {
const base = candidates[idx] || API_BASE_URL
const url = options.url.replace(/^https?:\/\/[^/]+/, base)
const uploadOptions = { ...options, url }
uni.uploadFile({
...uploadOptions,
success: (res) => {
const statusCode = res.statusCode || 0
if (statusCode >= 200 && statusCode < 300) {
try {
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
return resolve(data)
} catch (e) {
return resolve(res.data)
}
}
if (idx + 1 < candidates.length) return uploadWithFallback(options, candidates, idx + 1, resolve, reject)
reject(new Error('HTTP ' + statusCode))
},
fail: (err) => {
if (idx + 1 < candidates.length) return uploadWithFallback(options, candidates, idx + 1, resolve, reject)
reject(err)
}
})
}
// 文件上传封装:自动注入租户/用户头并进行多地址回退
export function upload(path, filePath, formData = {}, name = 'file') {
return new Promise((resolve, reject) => {
const header = { 'X-Shop-Id': SHOP_ID }
if (ENABLE_DEFAULT_USER && DEFAULT_USER_ID) header['X-User-Id'] = DEFAULT_USER_ID
const options = { url: buildUrl(path), filePath, name, formData, header }
uploadWithFallback(options, API_BASE_URL_CANDIDATES, 0, resolve, reject)
})
}

View File

@@ -0,0 +1,164 @@
<template>
<view class="uploader">
<view class="grid" :style="{height: areaHeight+'rpx'}">
<movable-area class="area" :style="{height: areaHeight+'rpx'}">
<movable-view
v-for="(img, index) in innerList"
:key="img.uid"
class="cell"
:style="cellStyle(index)"
:direction="'all'"
:damping="40"
:friction="2"
:x="img.x"
:y="img.y"
@change="onMoving(index, $event)"
@touchend="onMoveEnd(index)"
>
<image :src="img.url" mode="aspectFill" class="thumb" @click="preview(index)" />
<view class="remove" @click.stop="remove(index)">×</view>
</movable-view>
<view v-if="innerList.length < max" class="adder" @click="choose">
<text></text>
</view>
</movable-area>
</view>
</view>
</template>
<script>
import { upload } from '../common/http.js'
const ITEM_SIZE = 210 // rpx
const GAP = 18 // rpx
const COLS = 3
function px(rpx) {
// 以 750 设计稿计算;此函数仅用于内部位置计算,不写入样式
return rpx
}
export default {
name: 'ImageUploader',
props: {
modelValue: { type: Array, default: () => [] },
max: { type: Number, default: 9 },
uploadPath: { type: String, default: '/api/attachments' },
uploadFieldName: { type: String, default: 'file' },
formData: { type: Object, default: () => ({ ownerType: 'product' }) }
},
data() {
return {
innerList: []
}
},
computed: {
areaHeight() {
const rows = Math.ceil((this.innerList.length + 1) / COLS) || 1
return rows * ITEM_SIZE + (rows - 1) * GAP
}
},
watch: {
modelValue: {
immediate: true,
handler(list) {
const mapped = (list || []).map((u, i) => ({
uid: String(i) + '_' + (u.id || u.url || Math.random().toString(36).slice(2)),
url: typeof u === 'string' ? u : (u.url || ''),
x: this.posOf(i).x,
y: this.posOf(i).y
}))
this.innerList = mapped
}
}
},
methods: {
posOf(index) {
const row = Math.floor(index / COLS)
const col = index % COLS
return { x: px(col * (ITEM_SIZE + GAP)), y: px(row * (ITEM_SIZE + GAP)) }
},
cellStyle(index) {
return {
width: ITEM_SIZE + 'rpx',
height: ITEM_SIZE + 'rpx'
}
},
preview(index) {
uni.previewImage({ urls: this.innerList.map(i => i.url), current: index })
},
remove(index) {
this.innerList.splice(index, 1)
this.reflow()
this.emit()
},
choose() {
const remain = this.max - this.innerList.length
if (remain <= 0) return
uni.chooseImage({ count: remain, success: async (res) => {
for (const path of res.tempFilePaths) {
await this.doUpload(path)
}
}})
},
async doUpload(filePath) {
try {
const resp = await upload(this.uploadPath, filePath, this.formData, this.uploadFieldName)
const url = resp?.url || resp?.data?.url || resp?.path || ''
if (!url) throw new Error('上传响应无 url')
this.innerList.push({ uid: Math.random().toString(36).slice(2), url, ...this.posOf(this.innerList.length) })
this.reflow()
this.emit()
} catch (e) {
uni.showToast({ title: '上传失败', icon: 'none' })
}
},
onMoving(index, e) {
// 实时更新移动中元素的位置
const { x, y } = e.detail
this.innerList[index].x = x
this.innerList[index].y = y
},
onMoveEnd(index) {
// 根据落点推算新的索引
const mv = this.innerList[index]
const col = Math.round(mv.x / (ITEM_SIZE + GAP))
const row = Math.round(mv.y / (ITEM_SIZE + GAP))
let newIndex = row * COLS + col
newIndex = Math.max(0, Math.min(newIndex, this.innerList.length - 1))
if (newIndex !== index) {
const moved = this.innerList.splice(index, 1)[0]
this.innerList.splice(newIndex, 0, moved)
}
this.reflow()
this.emit()
},
reflow() {
this.innerList.forEach((it, i) => {
const p = this.posOf(i)
it.x = p.x
it.y = p.y
})
},
emit() {
this.$emit('update:modelValue', this.innerList.map(i => i.url))
this.$emit('change', this.innerList.map(i => i.url))
}
}
}
</script>
<style>
.uploader { padding: 12rpx; background: #fff; }
.grid { position: relative; }
.area { width: 100%; position: relative; }
.cell { position: absolute; border-radius: 12rpx; overflow: hidden; box-shadow: 0 0 1rpx rgba(0,0,0,0.08); }
.thumb { width: 100%; height: 100%; }
.remove { position: absolute; right: 6rpx; top: 6rpx; background: rgba(0,0,0,0.45); color: #fff; width: 40rpx; height: 40rpx; text-align: center; line-height: 40rpx; border-radius: 20rpx; font-size: 28rpx; }
.adder { width: 210rpx; height: 210rpx; border: 2rpx dashed #ccc; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; color: #999; position: absolute; left: 0; top: 0; }
</style>

View File

@@ -5,6 +5,66 @@
"style": {
"navigationBarTitleText": "五金配件管家"
}
},
{
"path": "pages/order/create",
"style": {
"navigationBarTitleText": "开单"
}
},
{
"path": "pages/product/select",
"style": {
"navigationBarTitleText": "选择商品"
}
},
{
"path": "pages/product/list",
"style": {
"navigationBarTitleText": "货品列表"
}
},
{
"path": "pages/product/form",
"style": {
"navigationBarTitleText": "编辑货品"
}
},
{
"path": "pages/product/categories",
"style": {
"navigationBarTitleText": "类别管理"
}
},
{
"path": "pages/product/units",
"style": {
"navigationBarTitleText": "单位管理"
}
},
{
"path": "pages/product/settings",
"style": {
"navigationBarTitleText": "货品设置"
}
},
{
"path": "pages/customer/select",
"style": {
"navigationBarTitleText": "选择客户"
}
},
{
"path": "pages/supplier/select",
"style": {
"navigationBarTitleText": "选择供应商"
}
},
{
"path": "pages/account/select",
"style": {
"navigationBarTitleText": "选择账户"
}
}
],
"globalStyle": {

View File

@@ -0,0 +1,46 @@
<template>
<view class="page">
<scroll-view scroll-y class="list">
<view class="item" v-for="a in accounts" :key="a.id" @click="select(a)">
<view class="name">{{ a.name }}</view>
<view class="meta">{{ typeLabel(a.type) }} · 余额{{ a.balance?.toFixed ? a.balance.toFixed(2) : a.balance }}</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { get } from '../../common/http.js'
const TYPE_MAP = { cash: '现金', bank: '银行', alipay: '支付宝', wechat: '微信', other: '其他' }
export default {
data() { return { accounts: [] } },
async onLoad() {
try {
const res = await get('/api/accounts')
this.accounts = Array.isArray(res) ? res : (res?.list || [])
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
},
methods: {
select(a) {
const opener = getCurrentPages()[getCurrentPages().length-2]
if (opener && opener.$vm) {
opener.$vm.selectedAccountId = a.id
opener.$vm.selectedAccountName = a.name
}
uni.navigateBack()
},
typeLabel(t) { return TYPE_MAP[t] || t }
}
}
</script>
<style>
.page { display:flex; flex-direction: column; height: 100vh; }
.list { flex:1; }
.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }
.name { color:#333; margin-bottom: 6rpx; }
.meta { color:#888; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,51 @@
<template>
<view class="page">
<view class="search">
<input v-model="kw" placeholder="搜索客户名称/电话" @confirm="search" />
<button size="mini" @click="search">搜索</button>
</view>
<scroll-view scroll-y class="list">
<view class="item" v-for="c in customers" :key="c.id" @click="select(c)">
<view class="name">{{ c.name }}</view>
<view class="meta">{{ c.mobile || '—' }}</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { get } from '../../common/http.js'
export default {
data() { return { kw: '', customers: [] } },
onLoad() { this.search() },
methods: {
async search() {
try {
const res = await get('/api/customers', { kw: this.kw, page: 1, size: 50 })
this.customers = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
},
select(c) {
const opener = getCurrentPages()[getCurrentPages().length-2]
if (opener && opener.$vm) {
opener.$vm.order.customerId = c.id
opener.$vm.customerName = c.name
}
uni.navigateBack()
}
}
}
</script>
<style>
.page { display:flex; flex-direction: column; height: 100vh; }
.search { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; }
.search input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }
.list { flex:1; }
.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }
.name { color:#333; margin-bottom: 6rpx; }
.meta { color:#888; font-size: 24rpx; }
</style>

View File

@@ -12,23 +12,30 @@
<view class="kpi">
<view class="kpi-item">
<text class="kpi-label">今日销售额</text>
<text class="kpi-value">{{ todayAmount }}</text>
<text class="kpi-value">{{ kpi.todaySales }}</text>
</view>
<view class="kpi-item">
<text class="kpi-label">本月销售额</text>
<text class="kpi-value">{{ kpi.monthSales }}</text>
</view>
<view class="kpi-item">
<text class="kpi-label">本月利润</text>
<text class="kpi-value">{{ monthProfit }}</text>
<text class="kpi-value">{{ kpi.monthProfit }}</text>
</view>
<view class="kpi-item">
<text class="kpi-label">库存数量</text>
<text class="kpi-value">{{ stockQty }}</text>
<text class="kpi-label">库存商品数量</text>
<text class="kpi-value">{{ kpi.stockCount }}</text>
</view>
</view>
</view>
<!-- 告栏自动轮播可点击查看详情 -->
<!-- 广告栏自动轮播可点击查看详情 -->
<view class="notice">
<view class="notice-left"></view>
<swiper class="notice-swiper" circular autoplay interval="4000" duration="400" vertical>
<view class="notice-left">广</view>
<view v-if="loadingNotices" class="notice-swiper" style="display:flex;align-items:center;color:#6b5a2a;">加载中...</view>
<view v-else-if="noticeError" class="notice-swiper" style="display:flex;align-items:center;color:#dd524d;">{{ noticeError }}</view>
<view v-else-if="!notices.length" class="notice-swiper" style="display:flex;align-items:center;color:#6b5a2a;">暂无公告</view>
<swiper v-else class="notice-swiper" circular autoplay interval="4000" duration="400" vertical>
<swiper-item v-for="(n, idx) in notices" :key="idx">
<view class="notice-item" @click="onNoticeTap(n)">
<text class="notice-text">{{ n.text }}</text>
@@ -36,7 +43,6 @@
</view>
</swiper-item>
</swiper>
<view class="notice-right" @click="onNoticeList">更多</view>
</view>
<!-- 分割标题产品与功能 -->
@@ -63,12 +69,18 @@
<view class="tab" :class="{ active: activeTab==='home' }" @click="activeTab='home'">
<text>首页</text>
</view>
<view class="tab" :class="{ active: activeTab==='product' }" @click="goProduct">
<text>货品</text>
</view>
<view class="tab primary" @click="onCreateOrder">
<text>开单</text>
</view>
<view class="tab" :class="{ active: activeTab==='detail' }" @click="activeTab='detail'">
<text>明细</text>
</view>
<view class="tab" :class="{ active: activeTab==='report' }" @click="activeTab='report'">
<text>报表</text>
</view>
<view class="tab" :class="{ active: activeTab==='me' }" @click="activeTab='me'">
<text>我的</text>
</view>
@@ -77,20 +89,17 @@
</template>
<script>
import { get } from '../../common/http.js'
export default {
data() {
return {
todayAmount: '0.00',
monthProfit: '0.00',
stockQty: '0.00',
kpi: { todaySales: '0.00', monthSales: '0.00', monthProfit: '0.00', stockCount: '0' },
activeTab: 'home',
notices: [
{ text: '选材精工:不锈钢、合金钢,耐腐蚀更耐用', tag: '品质' },
{ text: '表面工艺:电镀锌/镀镍/发黑处理,性能均衡', tag: '工艺' },
{ text: '库存齐全:螺丝、螺母、垫圈、膨胀螺栓等现货', tag: '库存' },
{ text: '企业采购支持:批量优惠,次日发货', tag: '服务' }
],
notices: [],
loadingNotices: false,
noticeError: '',
features: [
{ key: 'product', title: '货品', img: '/static/icons/product.png', emoji: '📦' },
{ key: 'customer', title: '客户', img: '/static/icons/customer.png', emoji: '👥' },
{ key: 'sale', title: '销售', img: '/static/icons/sale.png', emoji: '💰' },
{ key: 'account', title: '账户', img: '/static/icons/account.png', emoji: '💳' },
@@ -103,23 +112,63 @@
]
}
},
onLoad() {
this.fetchMetrics()
this.fetchNotices()
},
methods: {
async fetchMetrics() {
try {
const d = await get('/api/dashboard/overview')
const toNum = v => (typeof v === 'number' ? v : Number(v || 0))
this.kpi = {
...this.kpi,
todaySales: toNum(d && d.todaySalesAmount).toFixed(2),
monthSales: toNum(d && d.monthSalesAmount).toFixed(2),
monthProfit: toNum(d && d.monthGrossProfit).toFixed(2),
stockCount: String((d && d.stockTotalQuantity) != null ? d.stockTotalQuantity : 0)
}
} catch (e) {
// 忽略错误,保留默认值
}
},
async fetchNotices() {
this.loadingNotices = true
this.noticeError = ''
try {
const list = await get('/api/notices')
this.notices = Array.isArray(list) ? list.map(n => ({
text: n.content || n.title || '',
tag: n.tag || ''
})) : []
} catch (e) {
this.noticeError = (e && e.message) || '公告加载失败'
} finally {
this.loadingNotices = false
}
},
onFeatureTap(item) {
if (item.key === 'product') {
uni.navigateTo({ url: '/pages/product/list' })
return
}
uni.showToast({ title: item.title + '(开发中)', icon: 'none' })
},
goProduct() {
this.activeTab = 'product'
uni.navigateTo({ url: '/pages/product/list' })
},
onCreateOrder() {
uni.showToast({ title: '开单(开发中)', icon: 'none' })
uni.navigateTo({ url: '/pages/order/create' })
},
onNoticeTap(n) {
uni.showModal({
title: '告',
content: n.text,
title: '广告',
content: n && (n.text || n.title || n.content) || '',
showCancel: false
})
},
onNoticeList() {
uni.showToast({ title: '公告列表(开发中)', icon: 'none' })
},
onIconError(item) {
item.img = ''
}
@@ -173,7 +222,7 @@
.notice-item { display: flex; align-items: center; gap: 12rpx; min-height: 72rpx; }
.notice-text { color: #4b3e19; font-size: 28rpx; line-height: 36rpx; font-weight: 600; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.notice-tag { color: #B4880F; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 999rpx; background: rgba(215,167,46,0.18); }
.notice-right { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; min-width: 72rpx; height: 44rpx; color: #B4880F; font-size: 26rpx; padding-left: 8rpx; }
/* 分割标题 */
.section-title { display: flex; align-items: center; gap: 16rpx; padding: 10rpx 28rpx 0; }

View File

@@ -0,0 +1,224 @@
<template>
<view class="order">
<!-- 顶部 Tab -->
<view class="tabs">
<text :class="{ active: biz==='sale' }" @click="switchBiz('sale')">销售</text>
<text :class="{ active: biz==='purchase' }" @click="switchBiz('purchase')">进货</text>
<text :class="{ active: biz==='income' }" @click="switchBiz('income')">其他收入</text>
<text :class="{ active: biz==='expense' }" @click="switchBiz('expense')">其他支出</text>
</view>
<!-- 子类目按钮 -->
<view class="subtabs" v-if="biz==='sale'">
<button class="subbtn" :class="{ active: saleType==='out' }" @click="saleType='out'">出货</button>
<button class="subbtn" :class="{ active: saleType==='return' }" @click="saleType='return'">退货</button>
<button class="subbtn" :class="{ active: saleType==='collect' }" @click="saleType='collect'">收款</button>
</view>
<view class="subtabs" v-else-if="biz==='purchase'">
<button class="subbtn" :class="{ active: purchaseType==='in' }" @click="purchaseType='in'">进货</button>
<button class="subbtn" :class="{ active: purchaseType==='return' }" @click="purchaseType='return'">退货</button>
<button class="subbtn" :class="{ active: purchaseType==='pay' }" @click="purchaseType='pay'">付款</button>
</view>
<!-- 日期与客户 -->
<picker mode="date" :value="order.orderTime" @change="onDateChange">
<view class="field">
<text class="label">时间</text>
<text class="value">{{ order.orderTime }}</text>
</view>
</picker>
<view class="field" v-if="biz==='sale'" @click="chooseCustomer">
<text class="label">客户</text>
<text class="value">{{ customerLabel }}</text>
</view>
<view class="field" v-else-if="biz==='purchase'" @click="chooseSupplier">
<text class="label">供应商</text>
<text class="value">{{ supplierLabel }}</text>
</view>
<!-- 已选商品与合计销售/进货 -->
<view v-if="biz==='sale' || biz==='purchase'">
<view class="summary">
<text>选中货品{{ totalQuantity }}</text>
<text>合计金额¥ {{ totalAmount.toFixed(2) }}</text>
</view>
<!-- 加号添加商品 -->
<view class="add" @click="chooseProduct">+</view>
</view>
<!-- 其它收入/支出 表单 -->
<view v-else>
<view class="chips">
<view v-for="c in (biz==='income' ? incomeCategories : expenseCategories)" :key="c.key" class="chip" :class="{ active: activeCategory===c.key }" @click="activeCategory=c.key">{{ c.label }}</view>
</view>
<view class="field" @click="chooseCounterparty">
<text class="label">往来单位</text>
<text class="value">{{ counterpartyLabel }}</text>
</view>
<view class="field" @click="chooseAccount">
<text class="label">结算账户</text>
<text class="value">{{ accountLabel }}</text>
</view>
<view class="field">
<text class="label">金额</text>
<input class="value" type="digit" v-model.number="trxAmount" placeholder="0.00" />
</view>
<view class="textarea">
<textarea v-model="order.remark" maxlength="200" placeholder="备注最多输入200个字"></textarea>
</view>
</view>
<!-- 购物车空态 -->
<view class="empty" v-if="!items.length">
<image src="/static/logo.png" mode="widthFix" class="empty-img"></image>
<text class="empty-text">购物车里空空如也</text>
<text class="empty-sub">扫描或点击 + 选择商品吧</text>
</view>
<!-- 商品列表 -->
<view v-else class="list">
<view class="row" v-for="(it, idx) in items" :key="idx">
<view class="col name">{{ it.productName }}</view>
<view class="col qty">
<input type="number" v-model.number="it.quantity" @input="recalc()" />
</view>
<view class="col price">
<input type="number" v-model.number="it.unitPrice" @input="recalc()" />
</view>
<view class="col amount">¥ {{ (Number(it.quantity)*Number(it.unitPrice)).toFixed(2) }}</view>
</view>
</view>
<!-- 底部提交栏 -->
<view class="bottom">
<button class="ghost" @click="saveAndReset">再记一笔</button>
<button class="primary" @click="submit">保存</button>
</view>
</view>
</template>
<script>
import { post } from '../../common/http.js'
import { INCOME_CATEGORIES, EXPENSE_CATEGORIES } from '../../common/constants.js'
function todayString() {
const d = new Date()
const m = (d.getMonth()+1).toString().padStart(2,'0')
const day = d.getDate().toString().padStart(2,'0')
return `${d.getFullYear()}-${m}-${day}`
}
export default {
data() {
return {
biz: 'sale',
saleType: 'out',
purchaseType: 'in',
order: {
orderTime: todayString(),
customerId: null,
supplierId: null,
remark: ''
},
customerName: '',
supplierName: '',
items: [],
activeCategory: 'sale_income',
trxAmount: 0,
selectedAccountId: null,
selectedAccountName: ''
}
},
computed: {
totalQuantity() {
return this.items.reduce((s, it) => s + Number(it.quantity || 0), 0)
},
totalAmount() {
return this.items.reduce((s, it) => s + Number(it.quantity || 0) * Number(it.unitPrice || 0), 0)
},
customerLabel() { return this.customerName || '零售客户' },
supplierLabel() { return this.supplierName || '零散供应商' },
incomeCategories() { return INCOME_CATEGORIES },
expenseCategories() { return EXPENSE_CATEGORIES },
accountLabel() { return this.selectedAccountName || '现金' },
counterpartyLabel() { return this.customerName || this.supplierName || '—' }
},
methods: {
switchBiz(type) { this.biz = type },
onDateChange(e) { this.order.orderTime = e.detail.value },
chooseCustomer() {
uni.navigateTo({ url: '/pages/customer/select' })
},
chooseSupplier() { uni.navigateTo({ url: '/pages/supplier/select' }) },
chooseProduct() {
uni.navigateTo({ url: '/pages/product/select' })
},
chooseAccount() { uni.navigateTo({ url: '/pages/account/select' }) },
chooseCounterparty() {
if (this.biz==='income' || this.biz==='expense') {
uni.navigateTo({ url: '/pages/customer/select' })
}
},
recalc() { this.$forceUpdate() },
async submit() {
const isSaleOrPurchase = (this.biz==='sale' || this.biz==='purchase')
const payload = isSaleOrPurchase ? {
type: this.biz === 'sale' ? (this.saleType) : ('purchase.' + this.purchaseType),
orderTime: this.order.orderTime,
customerId: this.order.customerId,
supplierId: this.order.supplierId,
items: this.items.map(it => ({ productId: it.productId, quantity: Number(it.quantity||0), unitPrice: Number(it.unitPrice||0) })),
amount: this.totalAmount
} : {
type: this.biz,
category: this.activeCategory,
counterpartyId: this.order.customerId || null,
accountId: this.selectedAccountId || null,
amount: Number(this.trxAmount||0),
txTime: this.order.orderTime,
remark: this.order.remark
}
try {
const url = isSaleOrPurchase ? '/api/orders' : '/api/other-transactions'
await post(url, payload)
uni.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => { uni.navigateBack() }, 600)
} catch (e) {
uni.showToast({ title: e && e.message || '保存失败', icon: 'none' })
}
},
saveAndReset() {
this.items = []
this.trxAmount = 0
this.order.remark = ''
}
}
}
</script>
<style>
.order { padding-bottom: 140rpx; }
.tabs { display: flex; justify-content: space-around; padding: 16rpx 24rpx; }
.tabs text { color: #666; }
.tabs text.active { color: #333; font-weight: 700; }
.subtabs { display: flex; gap: 16rpx; padding: 0 24rpx 16rpx; }
.subbtn { padding: 10rpx 20rpx; border-radius: 999rpx; background: #f4f4f4; color: #666; }
.subbtn.active { background: #ffe69a; color: #3f320f; }
.field { display:flex; justify-content: space-between; padding: 22rpx 24rpx; background: #fff; border-bottom: 1rpx solid #eee; }
.label { color:#666; }
.value { color:#333; }
.summary { display:flex; justify-content: space-between; padding: 22rpx 24rpx; color:#333; }
.add { margin: 24rpx auto; width: 120rpx; height: 120rpx; border-radius: 20rpx; background: #c7eef7; color:#16a1c4; font-size: 72rpx; display:flex; align-items:center; justify-content:center; }
.empty { display:flex; flex-direction: column; align-items:center; padding: 60rpx 0; color:#888; }
.empty-img { width: 220rpx; margin-bottom: 20rpx; }
.empty-text { margin-bottom: 8rpx; }
.list { background:#fff; }
.row { display:grid; grid-template-columns: 1.5fr 1fr 1fr 1fr; gap: 12rpx; padding: 16rpx 12rpx; align-items:center; border-bottom: 1rpx solid #f3f3f3; }
.col.name { padding-left: 12rpx; }
.col.amount { text-align:right; padding-right: 12rpx; color:#333; }
.bottom { position: fixed; left:0; right:0; bottom:0; background:#fff; padding: 16rpx 24rpx calc(env(safe-area-inset-bottom) + 16rpx); box-shadow: 0 -4rpx 12rpx rgba(0,0,0,0.06); }
.primary { width: 100%; background: linear-gradient(135deg, #FFE69A 0%, #F4CF62 45%, #D7A72E 100%); color:#493c1b; border-radius: 999rpx; padding: 20rpx 0; font-weight:800; }
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view class="page">
<view class="toolbar">
<input v-model.trim="name" placeholder="新类别名称" />
<button size="mini" @click="create">新增</button>
</view>
<scroll-view scroll-y class="list">
<view class="item" v-for="c in list" :key="c.id">
<input v-model.trim="c.name" />
<view class="ops">
<button size="mini" @click="update(c)">保存</button>
<button size="mini" type="warn" @click="remove(c)">删除</button>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { get, post, put, del } from '../../common/http.js'
export default {
data() {
return { name: '', list: [] }
},
onLoad() { this.reload() },
methods: {
async reload() {
try {
const res = await get('/api/product-categories')
this.list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch (_) {}
},
async create() {
if (!this.name) return
await post('/api/product-categories', { name: this.name })
this.name = ''
this.reload()
},
async update(c) {
await put('/api/product-categories/' + c.id, { name: c.name })
uni.showToast({ title: '已保存', icon: 'success' })
},
async remove(c) {
uni.showModal({ content: '确定删除该类别?', success: async (r) => {
if (!r.confirm) return
await del('/api/product-categories/' + c.id)
this.reload()
}})
}
}
}
</script>
<style>
.page { display:flex; flex-direction: column; height: 100vh; }
.toolbar { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; }
.toolbar input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }
.list { flex:1; }
.item { display:flex; gap: 12rpx; align-items:center; padding: 16rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }
.item input { flex:1; background:#f7f7f7; border-radius: 10rpx; padding: 12rpx; }
.ops { display:flex; gap: 10rpx; }
</style>

View File

@@ -0,0 +1,213 @@
<template>
<scroll-view scroll-y class="page">
<view class="card">
<view class="row">
<text class="label">商品名称</text>
<input v-model.trim="form.name" placeholder="必填" />
</view>
<view class="row">
<text class="label">条形码</text>
<input v-model.trim="form.barcode" placeholder="可扫码或输入" />
<!-- #ifdef APP-PLUS -->
<button size="mini" @click="scan">扫码</button>
<!-- #endif -->
</view>
<view class="row">
<text class="label">品牌/型号/规格/产地</text>
</view>
<view class="row">
<input v-model.trim="form.brand" placeholder="品牌" />
</view>
<view class="row">
<input v-model.trim="form.model" placeholder="型号" />
</view>
<view class="row">
<input v-model.trim="form.spec" placeholder="规格" />
</view>
<view class="row">
<input v-model.trim="form.origin" placeholder="产地" />
</view>
<view class="row">
<picker mode="selector" :range="unitNames" @change="onPickUnit">
<view class="picker">主单位{{ unitLabel }}</view>
</picker>
<picker mode="selector" :range="categoryNames" @change="onPickCategory">
<view class="picker">类别{{ categoryLabel }}</view>
</picker>
</view>
</view>
<view class="card">
<view class="row">
<text class="label">库存与安全库存</text>
</view>
<view class="row">
<input type="number" v-model.number="form.stock" placeholder="当前库存" />
<input type="number" v-model.number="form.safeMin" placeholder="安全库存下限" />
<input type="number" v-model.number="form.safeMax" placeholder="安全库存上限" />
</view>
</view>
<view class="card">
<view class="row">
<text class="label">价格进价/零售/批发/大单</text>
</view>
<view class="row prices">
<input type="number" v-model.number="form.purchasePrice" placeholder="进货价" />
<input type="number" v-model.number="form.retailPrice" placeholder="零售价" />
<input type="number" v-model.number="form.wholesalePrice" placeholder="批发价" />
<input type="number" v-model.number="form.bigClientPrice" placeholder="大单价" />
</view>
</view>
<view class="card">
<text class="label">图片</text>
<ImageUploader v-model="form.images" :formData="{ ownerType: 'product' }" />
</view>
<view class="card">
<text class="label">备注</text>
<textarea v-model.trim="form.remark" placeholder="可选" auto-height />
</view>
<view class="fixed">
<button type="default" @click="save(false)">保存</button>
<button type="primary" @click="save(true)">保存并继续</button>
</view>
</scroll-view>
</template>
<script>
import ImageUploader from '../../components/ImageUploader.vue'
import { get, post, put } from '../../common/http.js'
export default {
components: { ImageUploader },
data() {
return {
id: '',
form: {
name: '', barcode: '', brand: '', model: '', spec: '', origin: '',
categoryId: '', unitId: '',
stock: null, safeMin: null, safeMax: null,
purchasePrice: null, retailPrice: null, wholesalePrice: null, bigClientPrice: null,
images: [], remark: ''
},
units: [],
categories: []
}
},
onLoad(query) {
this.id = query?.id || ''
this.bootstrap()
},
computed: {
unitNames() { return this.units.map(u => u.name) },
categoryNames() { return this.categories.map(c => c.name) },
unitLabel() {
const u = this.units.find(x => String(x.id) === String(this.form.unitId))
return u ? u.name : '选择单位'
},
categoryLabel() {
const c = this.categories.find(x => String(x.id) === String(this.form.categoryId))
return c ? c.name : '选择类别'
}
},
methods: {
async bootstrap() {
await Promise.all([this.fetchUnits(), this.fetchCategories()])
if (this.id) this.loadDetail()
},
async fetchUnits() {
try {
const res = await get('/api/product-units')
this.units = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch (_) {}
},
async fetchCategories() {
try {
const res = await get('/api/product-categories')
this.categories = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch (_) {}
},
onPickUnit(e) {
const idx = Number(e.detail.value); const u = this.units[idx]
this.form.unitId = u ? u.id : ''
},
onPickCategory(e) {
const idx = Number(e.detail.value); const c = this.categories[idx]
this.form.categoryId = c ? c.id : ''
},
scan() {
uni.scanCode({ onlyFromCamera: false, success: (res) => {
this.form.barcode = res.result
}})
},
async loadDetail() {
try {
const data = await get('/api/products/' + this.id)
Object.assign(this.form, {
name: data.name,
barcode: data.barcode, brand: data.brand, model: data.model, spec: data.spec, origin: data.origin,
categoryId: data.categoryId, unitId: data.unitId,
stock: data.stock,
safeMin: data.safeMin, safeMax: data.safeMax,
purchasePrice: data.purchasePrice, retailPrice: data.retailPrice,
wholesalePrice: data.wholesalePrice, bigClientPrice: data.bigClientPrice,
images: (data.images || []).map(i => i.url || i)
})
} catch (_) {}
},
validate() {
if (!this.form.name) { uni.showToast({ title: '请填写名称', icon: 'none' }); return false }
if (this.form.safeMin != null && this.form.safeMax != null && Number(this.form.safeMin) > Number(this.form.safeMax)) {
uni.showToast({ title: '安全库存区间不合法', icon: 'none' }); return false
}
return true
},
buildPayload() {
const f = this.form
return {
name: f.name, barcode: f.barcode, brand: f.brand, model: f.model, spec: f.spec, origin: f.origin,
categoryId: f.categoryId || null, unitId: f.unitId,
safeMin: f.safeMin, safeMax: f.safeMax,
prices: {
purchasePrice: f.purchasePrice, retailPrice: f.retailPrice, wholesalePrice: f.wholesalePrice, bigClientPrice: f.bigClientPrice
},
stock: f.stock,
images: f.images,
remark: f.remark
}
},
async save(goOn) {
if (!this.validate()) return
const payload = this.buildPayload()
try {
if (this.id) await put('/api/products/' + this.id, payload)
else await post('/api/products', payload)
uni.showToast({ title: '保存成功', icon: 'success' })
if (goOn && !this.id) {
this.form = { name: '', barcode: '', brand: '', model: '', spec: '', origin: '', categoryId: '', unitId: '', stock: null, safeMin: null, safeMax: null, purchasePrice: null, retailPrice: null, wholesalePrice: null, bigClientPrice: null, images: [], remark: '' }
} else {
setTimeout(() => uni.navigateBack(), 400)
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
}
}
}
}
</script>
<style>
.page { background:#f6f6f6; height: 100vh; }
.card { background:#fff; margin: 16rpx; padding: 16rpx; border-radius: 12rpx; }
.row { display:flex; gap: 12rpx; align-items: center; margin-bottom: 12rpx; }
.label { width: 180rpx; color:#666; }
.row input { flex:1; background:#f7f7f7; border-radius: 10rpx; padding: 12rpx; }
.picker { padding: 8rpx 12rpx; background:#f0f0f0; border-radius: 10rpx; color:#666; margin-left: 8rpx; }
.prices input { width: 30%; }
.fixed { position: fixed; left: 0; right: 0; bottom: 0; background:#fff; padding: 12rpx 16rpx; display:flex; gap: 16rpx; }
</style>

View File

@@ -0,0 +1,133 @@
<template>
<view class="page">
<view class="tabs">
<view class="tab" :class="{active: tab==='all'}" @click="switchTab('all')">全部</view>
<view class="tab" :class="{active: tab==='category'}" @click="switchTab('category')">按类别</view>
</view>
<view class="search">
<input v-model.trim="query.kw" placeholder="输入名称/条码/规格查询" @confirm="reload" />
<picker mode="selector" :range="categoryNames" v-if="tab==='category'" @change="onPickCategory">
<view class="picker">{{ categoryLabel }}</view>
</picker>
<button size="mini" @click="reload">查询</button>
</view>
<scroll-view scroll-y class="list" @scrolltolower="loadMore">
<block v-if="items.length">
<view class="item" v-for="it in items" :key="it.id" @click="openForm(it.id)">
<image v-if="it.cover" :src="it.cover" class="thumb" mode="aspectFill" />
<view class="content">
<view class="name">{{ it.name }}</view>
<view class="meta">{{ it.brand || '-' }} {{ it.model || '' }} {{ it.spec || '' }}</view>
<view class="meta">库存{{ it.stock ?? 0 }}
<text class="price">零售价¥{{ (it.retailPrice ?? it.price ?? 0).toFixed(2) }}</text>
</view>
</view>
</view>
</block>
<view v-else class="empty">
<text>暂无数据点击右上角新增</text>
</view>
</scroll-view>
<view class="fab" @click="openForm()"></view>
</view>
</template>
<script>
import { get } from '../../common/http.js'
export default {
data() {
return {
items: [],
query: { kw: '', page: 1, size: 20, categoryId: '' },
finished: false,
loading: false,
tab: 'all',
categories: []
}
},
onLoad() {
this.fetchCategories()
this.reload()
},
computed: {
categoryNames() { return this.categories.map(c => c.name) },
categoryLabel() {
const c = this.categories.find(x => String(x.id) === String(this.query.categoryId))
return c ? '类别:' + c.name : '选择类别'
}
},
methods: {
switchTab(t) {
this.tab = t
this.query.categoryId = ''
this.reload()
},
onPickCategory(e) {
const idx = Number(e.detail.value)
const c = this.categories[idx]
this.query.categoryId = c ? c.id : ''
this.reload()
},
async fetchCategories() {
try {
const res = await get('/api/product-categories', {})
this.categories = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch (_) {}
},
reload() {
this.items = []
this.query.page = 1
this.finished = false
this.loadMore()
},
async loadMore() {
if (this.loading || this.finished) return
this.loading = true
try {
const params = { kw: this.query.kw, page: this.query.page, size: this.query.size }
if (this.tab === 'category' && this.query.categoryId) params.categoryId = this.query.categoryId
const res = await get('/api/products', params)
const list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
this.items = this.items.concat(list)
if (list.length < this.query.size) this.finished = true
this.query.page += 1
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
this.loading = false
}
},
openForm(id) {
const url = '/pages/product/form' + (id ? ('?id=' + id) : '')
uni.navigateTo({ url })
}
}
}
</script>
<style>
.page { display:flex; flex-direction: column; height: 100vh; }
.tabs { display:flex; background:#fff; }
.tab { flex:1; text-align:center; padding: 20rpx 0; color:#666; }
.tab.active { color:#18b566; font-weight: 600; }
.search { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; align-items: center; }
.search input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }
.picker { padding: 8rpx 12rpx; background:#f0f0f0; border-radius: 10rpx; color:#666; }
.list { flex:1; }
.item { display:flex; padding: 20rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }
.thumb { width: 120rpx; height: 120rpx; border-radius: 12rpx; margin-right: 16rpx; background:#fafafa; }
.content { flex:1; }
.name { color:#333; margin-bottom: 6rpx; font-weight: 600; }
.meta { color:#888; font-size: 24rpx; }
.price { margin-left: 20rpx; color:#f60; }
.empty { height: 60vh; display:flex; align-items:center; justify-content:center; color:#999; }
.fab { position: fixed; right: 30rpx; bottom: 120rpx; width: 100rpx; height: 100rpx; background:#18b566; color:#fff; border-radius: 50rpx; text-align:center; line-height: 100rpx; font-size: 48rpx; box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.15); }
</style>

View File

@@ -0,0 +1,50 @@
<template>
<view class="page">
<view class="search">
<input v-model="kw" placeholder="搜索商品名称/编码" @confirm="search" />
<button size="mini" @click="search">搜索</button>
</view>
<scroll-view scroll-y class="list">
<view class="item" v-for="p in products" :key="p.id" @click="select(p)">
<view class="name">{{ p.name }}</view>
<view class="meta">{{ p.code }} · 库存{{ p.stock || 0 }}</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { get } from '../../common/http.js'
export default {
data() { return { kw: '', products: [] } },
onLoad() { this.search() },
methods: {
async search() {
try {
const res = await get('/api/products', { kw: this.kw, page: 1, size: 50 })
this.products = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
},
select(p) {
const opener = getCurrentPages()[getCurrentPages().length-2]
if (opener && opener.$vm && opener.$vm.items) {
opener.$vm.items.push({ productId: p.id, productName: p.name, quantity: 1, unitPrice: Number(p.price || 0) })
}
uni.navigateBack()
}
}
}
</script>
<style>
.page { display:flex; flex-direction: column; height: 100vh; }
.search { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; }
.search input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }
.list { flex:1; }
.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }
.name { color:#333; margin-bottom: 6rpx; }
.meta { color:#888; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,45 @@
<template>
<view class="page">
<view class="item">
<text>隐藏零库存商品</text>
<switch :checked="settings.hideZeroStock" @change="(e)=>update('hideZeroStock', e.detail.value)" />
</view>
<view class="item">
<text>隐藏进货价</text>
<switch :checked="settings.hidePurchasePrice" @change="(e)=>update('hidePurchasePrice', e.detail.value)" />
</view>
</view>
</template>
<script>
import { get, put } from '../../common/http.js'
export default {
data() {
return { settings: { hideZeroStock: false, hidePurchasePrice: false } }
},
onLoad() { this.load() },
methods: {
async load() {
try {
const res = await get('/api/product-settings')
this.settings = { hideZeroStock: !!res?.hideZeroStock, hidePurchasePrice: !!res?.hidePurchasePrice }
} catch (_) {}
},
async update(key, val) {
const next = { ...this.settings, [key]: val }
this.settings = next
try { await put('/api/product-settings', next) } catch (_) {}
}
}
}
</script>
<style>
.page { background:#fff; }
.item { display:flex; justify-content: space-between; align-items:center; padding: 20rpx; border-bottom: 1rpx solid #f1f1f1; }
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view class="page">
<view class="toolbar">
<input v-model.trim="name" placeholder="新单位名称" />
<button size="mini" @click="create">新增</button>
</view>
<scroll-view scroll-y class="list">
<view class="item" v-for="u in list" :key="u.id">
<input v-model.trim="u.name" />
<view class="ops">
<button size="mini" @click="update(u)">保存</button>
<button size="mini" type="warn" @click="remove(u)">删除</button>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { get, post, put, del } from '../../common/http.js'
export default {
data() {
return { name: '', list: [] }
},
onLoad() { this.reload() },
methods: {
async reload() {
try {
const res = await get('/api/product-units')
this.list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch (_) {}
},
async create() {
if (!this.name) return
await post('/api/product-units', { name: this.name })
this.name = ''
this.reload()
},
async update(u) {
await put('/api/product-units/' + u.id, { name: u.name })
uni.showToast({ title: '已保存', icon: 'success' })
},
async remove(u) {
uni.showModal({ content: '确定删除该单位?', success: async (r) => {
if (!r.confirm) return
await del('/api/product-units/' + u.id)
this.reload()
}})
}
}
}
</script>
<style>
.page { display:flex; flex-direction: column; height: 100vh; }
.toolbar { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; }
.toolbar input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }
.list { flex:1; }
.item { display:flex; gap: 12rpx; align-items:center; padding: 16rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }
.item input { flex:1; background:#f7f7f7; border-radius: 10rpx; padding: 12rpx; }
.ops { display:flex; gap: 10rpx; }
</style>

View File

@@ -0,0 +1,51 @@
<template>
<view class="page">
<view class="search">
<input v-model="kw" placeholder="搜索供应商名称/电话" @confirm="search" />
<button size="mini" @click="search">搜索</button>
</view>
<scroll-view scroll-y class="list">
<view class="item" v-for="s in suppliers" :key="s.id" @click="select(s)">
<view class="name">{{ s.name }}</view>
<view class="meta">{{ s.mobile || '—' }}</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { get } from '../../common/http.js'
export default {
data() { return { kw: '', suppliers: [] } },
onLoad() { this.search() },
methods: {
async search() {
try {
const res = await get('/api/suppliers', { kw: this.kw, page: 1, size: 50 })
this.suppliers = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }
},
select(s) {
const opener = getCurrentPages()[getCurrentPages().length-2]
if (opener && opener.$vm) {
opener.$vm.order.supplierId = s.id
opener.$vm.supplierName = s.name
}
uni.navigateBack()
}
}
}
</script>
<style>
.page { display:flex; flex-direction: column; height: 100vh; }
.search { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; }
.search input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }
.list { flex:1; }
.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }
.name { color:#333; margin-bottom: 6rpx; }
.meta { color:#888; font-size: 24rpx; }
</style>

View File

@@ -1 +1 @@
{"version":3,"file":"app.js","sources":["App.vue","main.js"],"sourcesContent":["<script>\r\n\texport default {\r\n\t\tonLaunch: function() {\r\n\t\t\tconsole.log('App Launch')\r\n\t\t},\r\n\t\tonShow: function() {\r\n\t\t\tconsole.log('App Show')\r\n\t\t},\r\n\t\tonHide: function() {\r\n\t\t\tconsole.log('App Hide')\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style>\r\n\t/*每个页面公共css */\r\n</style>\n","import App from './App'\n\n// #ifndef VUE3\nimport Vue from 'vue'\nimport './uni.promisify.adaptor'\nVue.config.productionTip = false\nApp.mpType = 'app'\nconst app = new Vue({\n ...App\n})\napp.$mount()\n// #endif\n\n// #ifdef VUE3\nimport { createSSRApp } from 'vue'\nexport function createApp() {\n const app = createSSRApp(App)\n return {\n app\n }\n}\n// #endif"],"names":["uni","createSSRApp","App"],"mappings":";;;;;;AACC,MAAK,YAAU;AAAA,EACd,UAAU,WAAW;AACpBA,kBAAAA,MAAA,MAAA,OAAA,gBAAY,YAAY;AAAA,EACxB;AAAA,EACD,QAAQ,WAAW;AAClBA,kBAAAA,MAAY,MAAA,OAAA,gBAAA,UAAU;AAAA,EACtB;AAAA,EACD,QAAQ,WAAW;AAClBA,kBAAAA,MAAY,MAAA,OAAA,iBAAA,UAAU;AAAA,EACvB;AACD;ACIM,SAAS,YAAY;AAC1B,QAAM,MAAMC,cAAY,aAACC,SAAG;AAC5B,SAAO;AAAA,IACL;AAAA,EACD;AACH;;;"}
{"version":3,"file":"app.js","sources":["App.vue","main.js"],"sourcesContent":["<script>\r\n\texport default {\r\n\t\tonLaunch: function() {\r\n\t\t\tconsole.log('App Launch')\r\n\t\t},\r\n\t\tonShow: function() {\r\n\t\t\tconsole.log('App Show')\r\n\t\t},\r\n\t\tonHide: function() {\r\n\t\t\tconsole.log('App Hide')\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style>\r\n\t/*每个页面公共css */\r\n</style>\r\n","import App from './App'\r\n\r\n// #ifndef VUE3\r\nimport Vue from 'vue'\r\nimport './uni.promisify.adaptor'\r\nVue.config.productionTip = false\r\nApp.mpType = 'app'\r\nconst app = new Vue({\r\n ...App\r\n})\r\napp.$mount()\r\n// #endif\r\n\r\n// #ifdef VUE3\r\nimport { createSSRApp } from 'vue'\r\nexport function createApp() {\r\n const app = createSSRApp(App)\r\n return {\r\n app\r\n }\r\n}\r\n// #endif"],"names":["uni","createSSRApp","App"],"mappings":";;;;;;;;;;;;;;;;AACC,MAAK,YAAU;AAAA,EACd,UAAU,WAAW;AACpBA,kBAAAA,MAAA,MAAA,OAAA,gBAAY,YAAY;AAAA,EACxB;AAAA,EACD,QAAQ,WAAW;AAClBA,kBAAAA,MAAY,MAAA,OAAA,gBAAA,UAAU;AAAA,EACtB;AAAA,EACD,QAAQ,WAAW;AAClBA,kBAAAA,MAAY,MAAA,OAAA,iBAAA,UAAU;AAAA,EACvB;AACD;ACIM,SAAS,YAAY;AAC1B,QAAM,MAAMC,cAAY,aAACC,SAAG;AAC5B,SAAO;AAAA,IACL;AAAA,EACD;AACH;;;"}

View File

@@ -1 +1 @@
{"version":3,"file":"assets.js","sources":["../../../static/metal-bg.jpg"],"sourcesContent":["export default \"/static/metal-bg.jpg\""],"names":[],"mappings":";AAAA,MAAe,aAAA;;"}
{"version":3,"file":"assets.js","sources":["../../../../../../static/metal-bg.jpg","static/logo.png"],"sourcesContent":["export default \"/static/metal-bg.jpg\"","export default \"__VITE_ASSET__46719607__\""],"names":[],"mappings":";AAAA,MAAe,eAAA;ACAf,MAAe,aAAA;;;"}

View File

@@ -0,0 +1 @@
{"version":3,"file":"config.js","sources":["common/config.js"],"sourcesContent":["// 统一配置:禁止在业务代码中硬编码\n// 优先级:环境变量(Vite/HBuilderX 构建注入) > 本地存储 > 默认值\n\nconst envBaseUrl = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_API_BASE_URL || process.env.API_BASE_URL)) || '';\nconst storageBaseUrl = typeof uni !== 'undefined' ? (uni.getStorageSync('API_BASE_URL') || '') : '';\nconst fallbackBaseUrl = 'http://192.168.31.193:8080';\n\nexport const API_BASE_URL = (envBaseUrl || storageBaseUrl || fallbackBaseUrl).replace(/\\/$/, '');\n\n// 多地址候选(按优先级顺序,自动去重与去尾斜杠)\nconst candidateBases = [envBaseUrl, storageBaseUrl, fallbackBaseUrl, 'http://127.0.0.1:8080', 'http://localhost:8080'];\nexport const API_BASE_URL_CANDIDATES = Array.from(new Set(candidateBases.filter(Boolean))).map(u => String(u).replace(/\\/$/, ''));\n\nconst envShopId = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_SHOP_ID || process.env.SHOP_ID)) || '';\nconst storageShopId = typeof uni !== 'undefined' ? (uni.getStorageSync('SHOP_ID') || '') : '';\nexport const SHOP_ID = Number(envShopId || storageShopId || 1);\n\n\n// 默认用户(可移除):\n// - 用途:开发/演示环境自动将用户固定为“张老板”id=2\n// - 开关优先级:环境变量 > 本地存储 > 默认值\n// - 生产默认关闭false开发可通过本地存储或环境变量开启\nconst envEnableDefaultUser = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_ENABLE_DEFAULT_USER || process.env.ENABLE_DEFAULT_USER)) || '';\nconst storageEnableDefaultUser = typeof uni !== 'undefined' ? (uni.getStorageSync('ENABLE_DEFAULT_USER') || '') : '';\nexport const ENABLE_DEFAULT_USER = String(envEnableDefaultUser || storageEnableDefaultUser || 'true').toLowerCase() === 'true';\n\nconst envDefaultUserId = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_DEFAULT_USER_ID || process.env.DEFAULT_USER_ID)) || '';\nconst storageDefaultUserId = typeof uni !== 'undefined' ? (uni.getStorageSync('DEFAULT_USER_ID') || '') : '';\nexport const DEFAULT_USER_ID = Number(envDefaultUserId || storageDefaultUserId || 2);\n\n\n"],"names":["uni"],"mappings":";;AAGA,MAAM,aAAc,OAAO,YAAY,eAAe,QAAQ,QAAQ,QAAQ,IAAI,yBAAyB,QAAQ,IAAI,iBAAkB;AACzI,MAAM,iBAAiB,OAAOA,cAAG,UAAK,cAAeA,cAAAA,MAAI,eAAe,cAAc,KAAK,KAAM;AACjG,MAAM,kBAAkB;AAEZ,MAAC,gBAAgB,cAAc,kBAAkB,iBAAiB,QAAQ,OAAO,EAAE;AAG/F,MAAM,iBAAiB,CAAC,YAAY,gBAAgB,iBAAiB,yBAAyB,uBAAuB;AACzG,MAAC,0BAA0B,MAAM,KAAK,IAAI,IAAI,eAAe,OAAO,OAAO,CAAC,CAAC,EAAE,IAAI,OAAK,OAAO,CAAC,EAAE,QAAQ,OAAO,EAAE,CAAC;AAEhI,MAAM,YAAa,OAAO,YAAY,eAAe,QAAQ,QAAQ,QAAQ,IAAI,oBAAoB,QAAQ,IAAI,YAAa;AAC9H,MAAM,gBAAgB,OAAOA,cAAG,UAAK,cAAeA,cAAAA,MAAI,eAAe,SAAS,KAAK,KAAM;AAC/E,MAAC,UAAU,OAAO,aAAa,iBAAiB,CAAC;AAO7D,MAAM,uBAAwB,OAAO,YAAY,eAAe,QAAQ,QAAQ,QAAQ,IAAI,gCAAgC,QAAQ,IAAI,wBAAyB;AACjK,MAAM,2BAA2B,OAAOA,cAAG,UAAK,cAAeA,cAAAA,MAAI,eAAe,qBAAqB,KAAK,KAAM;AACtG,MAAC,sBAAsB,OAAO,wBAAwB,4BAA4B,MAAM,EAAE,YAAW,MAAO;AAExH,MAAM,mBAAoB,OAAO,YAAY,eAAe,QAAQ,QAAQ,QAAQ,IAAI,4BAA4B,QAAQ,IAAI,oBAAqB;AACrJ,MAAM,uBAAuB,OAAOA,cAAG,UAAK,cAAeA,cAAAA,MAAI,eAAe,iBAAiB,KAAK,KAAM;AAC9F,MAAC,kBAAkB,OAAO,oBAAoB,wBAAwB,CAAC;;;;;;"}

View File

@@ -0,0 +1 @@
{"version":3,"file":"constants.js","sources":["common/constants.js"],"sourcesContent":["// 统一常量配置:其他收入/支出分类,禁止在业务中硬编码\r\nexport const INCOME_CATEGORIES = [\r\n\t{ key: 'sale_income', label: '销售收入' },\r\n\t{ key: 'operation_income', label: '经营所得' },\r\n\t{ key: 'interest_income', label: '利息收入' },\r\n\t{ key: 'investment_income', label: '投资收入' },\r\n\t{ key: 'other_income', label: '其它收入' }\r\n]\r\n\r\nexport const EXPENSE_CATEGORIES = [\r\n\t{ key: 'operation_expense', label: '经营支出' },\r\n\t{ key: 'office_supplies', label: '办公用品' },\r\n\t{ key: 'rent', label: '房租' },\r\n\t{ key: 'interest_expense', label: '利息支出' },\r\n\t{ key: 'other_expense', label: '其它支出' }\r\n]\r\n\r\n\r\n\r\n"],"names":[],"mappings":";AACY,MAAC,oBAAoB;AAAA,EAChC,EAAE,KAAK,eAAe,OAAO,OAAQ;AAAA,EACrC,EAAE,KAAK,oBAAoB,OAAO,OAAQ;AAAA,EAC1C,EAAE,KAAK,mBAAmB,OAAO,OAAQ;AAAA,EACzC,EAAE,KAAK,qBAAqB,OAAO,OAAQ;AAAA,EAC3C,EAAE,KAAK,gBAAgB,OAAO,OAAQ;AACvC;AAEY,MAAC,qBAAqB;AAAA,EACjC,EAAE,KAAK,qBAAqB,OAAO,OAAQ;AAAA,EAC3C,EAAE,KAAK,mBAAmB,OAAO,OAAQ;AAAA,EACzC,EAAE,KAAK,QAAQ,OAAO,KAAM;AAAA,EAC5B,EAAE,KAAK,oBAAoB,OAAO,OAAQ;AAAA,EAC1C,EAAE,KAAK,iBAAiB,OAAO,OAAQ;AACxC;;;"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"file":"select.js","sources":["pages/account/select.vue","../../../../Downloads/HBuilderX.4.76.2025082103/HBuilderX/plugins/uniapp-cli-vite/uniPage:/cGFnZXMvYWNjb3VudC9zZWxlY3QudnVl"],"sourcesContent":["<template>\r\n\t<view class=\"page\">\r\n\t\t<scroll-view scroll-y class=\"list\">\r\n\t\t\t<view class=\"item\" v-for=\"a in accounts\" :key=\"a.id\" @click=\"select(a)\">\r\n\t\t\t\t<view class=\"name\">{{ a.name }}</view>\r\n\t\t\t\t<view class=\"meta\">{{ typeLabel(a.type) }} · 余额:{{ a.balance?.toFixed ? a.balance.toFixed(2) : a.balance }}</view>\r\n\t\t\t</view>\r\n\t\t</scroll-view>\r\n\t</view>\r\n</template>\r\n\r\n<script>\r\n\timport { get } from '../../common/http.js'\r\n\tconst TYPE_MAP = { cash: '现金', bank: '银行', alipay: '支付宝', wechat: '微信', other: '其他' }\r\n\texport default {\r\n\t\tdata() { return { accounts: [] } },\r\n\t\tasync onLoad() {\r\n\t\t\ttry {\r\n\t\t\t\tconst res = await get('/api/accounts')\r\n\t\t\t\tthis.accounts = Array.isArray(res) ? res : (res?.list || [])\r\n\t\t\t} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }\r\n\t\t},\r\n\t\tmethods: {\r\n\t\t\tselect(a) {\r\n\t\t\t\tconst opener = getCurrentPages()[getCurrentPages().length-2]\r\n\t\t\t\tif (opener && opener.$vm) {\r\n\t\t\t\t\topener.$vm.selectedAccountId = a.id\r\n\t\t\t\t\topener.$vm.selectedAccountName = a.name\r\n\t\t\t\t}\r\n\t\t\t\tuni.navigateBack()\r\n\t\t\t},\r\n\t\t\ttypeLabel(t) { return TYPE_MAP[t] || t }\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style>\r\n\t.page { display:flex; flex-direction: column; height: 100vh; }\r\n\t.list { flex:1; }\r\n\t.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }\r\n\t.name { color:#333; margin-bottom: 6rpx; }\r\n\t.meta { color:#888; font-size: 24rpx; }\r\n</style>\r\n\r\n\r\n\r\n","import MiniProgramPage from 'C:/Users/21826/Desktop/Wj/PartsInquiry/frontend/pages/account/select.vue'\nwx.createPage(MiniProgramPage)"],"names":["get","uni"],"mappings":";;;AAaC,MAAM,WAAW,EAAE,MAAM,MAAM,MAAM,MAAM,QAAQ,OAAO,QAAQ,MAAM,OAAO,KAAK;AACpF,MAAK,YAAU;AAAA,EACd,OAAO;AAAE,WAAO,EAAE,UAAU,CAAG,EAAA;AAAA,EAAG;AAAA,EAClC,MAAM,SAAS;AACd,QAAI;AACH,YAAM,MAAM,MAAMA,YAAG,IAAC,eAAe;AACrC,WAAK,WAAW,MAAM,QAAQ,GAAG,IAAI,OAAO,2BAAK,SAAQ;aAClD,GAAG;AAAEC,oBAAAA,MAAI,UAAU,EAAE,OAAO,QAAQ,MAAM,OAAO,CAAC;AAAA,IAAE;AAAA,EAC5D;AAAA,EACD,SAAS;AAAA,IACR,OAAO,GAAG;AACT,YAAM,SAAS,gBAAiB,EAAC,gBAAe,EAAG,SAAO,CAAC;AAC3D,UAAI,UAAU,OAAO,KAAK;AACzB,eAAO,IAAI,oBAAoB,EAAE;AACjC,eAAO,IAAI,sBAAsB,EAAE;AAAA,MACpC;AACAA,oBAAAA,MAAI,aAAa;AAAA,IACjB;AAAA,IACD,UAAU,GAAG;AAAE,aAAO,SAAS,CAAC,KAAK;AAAA,IAAE;AAAA,EACxC;AACD;;;;;;;;;;;;;;;;AChCD,GAAG,WAAW,eAAe;"}

View File

@@ -0,0 +1 @@
{"version":3,"file":"select.js","sources":["pages/customer/select.vue","../../../../Downloads/HBuilderX.4.76.2025082103/HBuilderX/plugins/uniapp-cli-vite/uniPage:/cGFnZXMvY3VzdG9tZXIvc2VsZWN0LnZ1ZQ"],"sourcesContent":["<template>\r\n\t<view class=\"page\">\r\n\t\t<view class=\"search\">\r\n\t\t\t<input v-model=\"kw\" placeholder=\"搜索客户名称/电话\" @confirm=\"search\" />\r\n\t\t\t<button size=\"mini\" @click=\"search\">搜索</button>\r\n\t\t</view>\r\n\t\t<scroll-view scroll-y class=\"list\">\r\n\t\t\t<view class=\"item\" v-for=\"c in customers\" :key=\"c.id\" @click=\"select(c)\">\r\n\t\t\t\t<view class=\"name\">{{ c.name }}</view>\r\n\t\t\t\t<view class=\"meta\">{{ c.mobile || '—' }}</view>\r\n\t\t\t</view>\r\n\t\t</scroll-view>\r\n\t</view>\r\n</template>\r\n\r\n<script>\r\n\timport { get } from '../../common/http.js'\r\n\texport default {\r\n\t\tdata() { return { kw: '', customers: [] } },\r\n\t\tonLoad() { this.search() },\r\n\t\tmethods: {\r\n\t\t\tasync search() {\r\n\t\t\t\ttry {\r\n\t\t\t\t\tconst res = await get('/api/customers', { kw: this.kw, page: 1, size: 50 })\r\n\t\t\t\t\tthis.customers = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])\r\n\t\t\t\t} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }\r\n\t\t\t},\r\n\t\t\tselect(c) {\r\n\t\t\t\tconst opener = getCurrentPages()[getCurrentPages().length-2]\r\n\t\t\t\tif (opener && opener.$vm) {\r\n\t\t\t\t\topener.$vm.order.customerId = c.id\r\n\t\t\t\t\topener.$vm.customerName = c.name\r\n\t\t\t\t}\r\n\t\t\t\tuni.navigateBack()\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style>\r\n\t.page { display:flex; flex-direction: column; height: 100vh; }\r\n\t.search { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; }\r\n\t.search input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }\r\n\t.list { flex:1; }\r\n\t.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }\r\n\t.name { color:#333; margin-bottom: 6rpx; }\r\n\t.meta { color:#888; font-size: 24rpx; }\r\n</style>\r\n\r\n\r\n\r\n","import MiniProgramPage from 'C:/Users/21826/Desktop/Wj/PartsInquiry/frontend/pages/customer/select.vue'\nwx.createPage(MiniProgramPage)"],"names":["get","uni"],"mappings":";;;AAiBC,MAAK,YAAU;AAAA,EACd,OAAO;AAAE,WAAO,EAAE,IAAI,IAAI,WAAW,CAAA;EAAM;AAAA,EAC3C,SAAS;AAAE,SAAK;EAAU;AAAA,EAC1B,SAAS;AAAA,IACR,MAAM,SAAS;AACd,UAAI;AACH,cAAM,MAAM,MAAMA,gBAAI,kBAAkB,EAAE,IAAI,KAAK,IAAI,MAAM,GAAG,MAAM,GAAC,CAAG;AAC1E,aAAK,YAAY,MAAM,QAAQ,2BAAK,IAAI,IAAI,IAAI,OAAQ,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAA;AAAA,eAC5E,GAAG;AAAEC,sBAAAA,MAAI,UAAU,EAAE,OAAO,QAAQ,MAAM,OAAO,CAAC;AAAA,MAAE;AAAA,IAC5D;AAAA,IACD,OAAO,GAAG;AACT,YAAM,SAAS,gBAAiB,EAAC,gBAAe,EAAG,SAAO,CAAC;AAC3D,UAAI,UAAU,OAAO,KAAK;AACzB,eAAO,IAAI,MAAM,aAAa,EAAE;AAChC,eAAO,IAAI,eAAe,EAAE;AAAA,MAC7B;AACAA,oBAAAA,MAAI,aAAa;AAAA,IAClB;AAAA,EACD;AACD;;;;;;;;;;;;;;;;;;ACnCD,GAAG,WAAW,eAAe;"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"file":"categories.js","sources":["pages/product/categories.vue","../../../../Downloads/HBuilderX.4.76.2025082103/HBuilderX/plugins/uniapp-cli-vite/uniPage:/cGFnZXMvcHJvZHVjdC9jYXRlZ29yaWVzLnZ1ZQ"],"sourcesContent":["<template>\r\n\t<view class=\"page\">\r\n\t\t<view class=\"toolbar\">\r\n\t\t\t<input v-model.trim=\"name\" placeholder=\"新类别名称\" />\r\n\t\t\t<button size=\"mini\" @click=\"create\">新增</button>\r\n\t\t</view>\r\n\t\t<scroll-view scroll-y class=\"list\">\r\n\t\t\t<view class=\"item\" v-for=\"c in list\" :key=\"c.id\">\r\n\t\t\t\t<input v-model.trim=\"c.name\" />\r\n\t\t\t\t<view class=\"ops\">\r\n\t\t\t\t\t<button size=\"mini\" @click=\"update(c)\">保存</button>\r\n\t\t\t\t\t<button size=\"mini\" type=\"warn\" @click=\"remove(c)\">删除</button>\r\n\t\t\t\t</view>\r\n\t\t\t</view>\r\n\t\t</scroll-view>\r\n\t</view>\r\n</template>\r\n\r\n<script>\r\nimport { get, post, put, del } from '../../common/http.js'\r\n\r\nexport default {\r\n\tdata() {\r\n\t\treturn { name: '', list: [] }\r\n\t},\r\n\tonLoad() { this.reload() },\r\n\tmethods: {\r\n\t\tasync reload() {\r\n\t\t\ttry {\r\n\t\t\t\tconst res = await get('/api/product-categories')\r\n\t\t\t\tthis.list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])\r\n\t\t\t} catch (_) {}\r\n\t\t},\r\n\t\tasync create() {\r\n\t\t\tif (!this.name) return\r\n\t\t\tawait post('/api/product-categories', { name: this.name })\r\n\t\t\tthis.name = ''\r\n\t\t\tthis.reload()\r\n\t\t},\r\n\t\tasync update(c) {\r\n\t\t\tawait put('/api/product-categories/' + c.id, { name: c.name })\r\n\t\t\tuni.showToast({ title: '已保存', icon: 'success' })\r\n\t\t},\r\n\t\tasync remove(c) {\r\n\t\t\tuni.showModal({ content: '确定删除该类别?', success: async (r) => {\r\n\t\t\t\tif (!r.confirm) return\r\n\t\t\t\tawait del('/api/product-categories/' + c.id)\r\n\t\t\t\tthis.reload()\r\n\t\t\t}})\r\n\t\t}\r\n\t}\r\n}\r\n</script>\r\n\r\n<style>\r\n.page { display:flex; flex-direction: column; height: 100vh; }\r\n.toolbar { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; }\r\n.toolbar input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }\r\n.list { flex:1; }\r\n.item { display:flex; gap: 12rpx; align-items:center; padding: 16rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }\r\n.item input { flex:1; background:#f7f7f7; border-radius: 10rpx; padding: 12rpx; }\r\n.ops { display:flex; gap: 10rpx; }\r\n</style>\r\n\r\n\r\n\r\n\r\n","import MiniProgramPage from 'C:/Users/21826/Desktop/Wj/PartsInquiry/frontend/pages/product/categories.vue'\nwx.createPage(MiniProgramPage)"],"names":["get","post","put","uni","del"],"mappings":";;;AAqBA,MAAK,YAAU;AAAA,EACd,OAAO;AACN,WAAO,EAAE,MAAM,IAAI,MAAM,CAAA,EAAG;AAAA,EAC5B;AAAA,EACD,SAAS;AAAE,SAAK;EAAU;AAAA,EAC1B,SAAS;AAAA,IACR,MAAM,SAAS;AACd,UAAI;AACH,cAAM,MAAM,MAAMA,YAAG,IAAC,yBAAyB;AAC/C,aAAK,OAAO,MAAM,QAAQ,2BAAK,IAAI,IAAI,IAAI,OAAQ,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAA;AAAA,eACtE,GAAG;AAAA,MAAC;AAAA,IACb;AAAA,IACD,MAAM,SAAS;AACd,UAAI,CAAC,KAAK;AAAM;AAChB,YAAMC,YAAAA,KAAK,2BAA2B,EAAE,MAAM,KAAK,KAAG,CAAG;AACzD,WAAK,OAAO;AACZ,WAAK,OAAO;AAAA,IACZ;AAAA,IACD,MAAM,OAAO,GAAG;AACf,YAAMC,YAAG,IAAC,6BAA6B,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM;AAC7DC,oBAAG,MAAC,UAAU,EAAE,OAAO,OAAO,MAAM,WAAW;AAAA,IAC/C;AAAA,IACD,MAAM,OAAO,GAAG;AACfA,oBAAG,MAAC,UAAU,EAAE,SAAS,YAAY,SAAS,OAAO,MAAM;AAC1D,YAAI,CAAC,EAAE;AAAS;AAChB,cAAMC,gBAAI,6BAA6B,EAAE,EAAE;AAC3C,aAAK,OAAO;AAAA,MACb,EAAC,CAAC;AAAA,IACH;AAAA,EACD;AACD;;;;;;;;;;;;;;;;;;;;;;AClDA,GAAG,WAAW,eAAe;"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"file":"select.js","sources":["pages/product/select.vue","../../../../Downloads/HBuilderX.4.76.2025082103/HBuilderX/plugins/uniapp-cli-vite/uniPage:/cGFnZXMvcHJvZHVjdC9zZWxlY3QudnVl"],"sourcesContent":["<template>\r\n\t<view class=\"page\">\r\n\t\t<view class=\"search\">\r\n\t\t\t<input v-model=\"kw\" placeholder=\"搜索商品名称/编码\" @confirm=\"search\" />\r\n\t\t\t<button size=\"mini\" @click=\"search\">搜索</button>\r\n\t\t</view>\r\n\t\t<scroll-view scroll-y class=\"list\">\r\n\t\t\t<view class=\"item\" v-for=\"p in products\" :key=\"p.id\" @click=\"select(p)\">\r\n\t\t\t\t<view class=\"name\">{{ p.name }}</view>\r\n\t\t\t\t<view class=\"meta\">{{ p.code }} · 库存:{{ p.stock || 0 }}</view>\r\n\t\t\t</view>\r\n\t\t</scroll-view>\r\n\t</view>\r\n</template>\r\n\r\n<script>\r\n\timport { get } from '../../common/http.js'\r\n\texport default {\r\n\t\tdata() { return { kw: '', products: [] } },\r\n\t\tonLoad() { this.search() },\r\n\t\tmethods: {\r\n\t\t\tasync search() {\r\n\t\t\t\ttry {\r\n\t\t\t\t\tconst res = await get('/api/products', { kw: this.kw, page: 1, size: 50 })\r\n\t\t\t\t\tthis.products = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])\r\n\t\t\t\t} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }\r\n\t\t\t},\r\n\t\t\tselect(p) {\r\n\t\t\t\tconst opener = getCurrentPages()[getCurrentPages().length-2]\r\n\t\t\t\tif (opener && opener.$vm && opener.$vm.items) {\r\n\t\t\t\t\topener.$vm.items.push({ productId: p.id, productName: p.name, quantity: 1, unitPrice: Number(p.price || 0) })\r\n\t\t\t\t}\r\n\t\t\t\tuni.navigateBack()\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style>\r\n\t.page { display:flex; flex-direction: column; height: 100vh; }\r\n\t.search { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; }\r\n\t.search input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }\r\n\t.list { flex:1; }\r\n\t.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }\r\n\t.name { color:#333; margin-bottom: 6rpx; }\r\n\t.meta { color:#888; font-size: 24rpx; }\r\n</style>\r\n\r\n\r\n\r\n","import MiniProgramPage from 'C:/Users/21826/Desktop/Wj/PartsInquiry/frontend/pages/product/select.vue'\nwx.createPage(MiniProgramPage)"],"names":["get","uni"],"mappings":";;;AAiBC,MAAK,YAAU;AAAA,EACd,OAAO;AAAE,WAAO,EAAE,IAAI,IAAI,UAAU,CAAA;EAAM;AAAA,EAC1C,SAAS;AAAE,SAAK;EAAU;AAAA,EAC1B,SAAS;AAAA,IACR,MAAM,SAAS;AACd,UAAI;AACH,cAAM,MAAM,MAAMA,gBAAI,iBAAiB,EAAE,IAAI,KAAK,IAAI,MAAM,GAAG,MAAM,GAAC,CAAG;AACzE,aAAK,WAAW,MAAM,QAAQ,2BAAK,IAAI,IAAI,IAAI,OAAQ,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAA;AAAA,eAC3E,GAAG;AAAEC,sBAAAA,MAAI,UAAU,EAAE,OAAO,QAAQ,MAAM,OAAO,CAAC;AAAA,MAAE;AAAA,IAC5D;AAAA,IACD,OAAO,GAAG;AACT,YAAM,SAAS,gBAAiB,EAAC,gBAAe,EAAG,SAAO,CAAC;AAC3D,UAAI,UAAU,OAAO,OAAO,OAAO,IAAI,OAAO;AAC7C,eAAO,IAAI,MAAM,KAAK,EAAE,WAAW,EAAE,IAAI,aAAa,EAAE,MAAM,UAAU,GAAG,WAAW,OAAO,EAAE,SAAS,CAAC,GAAG;AAAA,MAC7G;AACAA,oBAAAA,MAAI,aAAa;AAAA,IAClB;AAAA,EACD;AACD;;;;;;;;;;;;;;;;;;;AClCD,GAAG,WAAW,eAAe;"}

View File

@@ -0,0 +1 @@
{"version":3,"file":"settings.js","sources":["pages/product/settings.vue","../../../../Downloads/HBuilderX.4.76.2025082103/HBuilderX/plugins/uniapp-cli-vite/uniPage:/cGFnZXMvcHJvZHVjdC9zZXR0aW5ncy52dWU"],"sourcesContent":["<template>\r\n\t<view class=\"page\">\r\n\t\t<view class=\"item\">\r\n\t\t\t<text>隐藏零库存商品</text>\r\n\t\t\t<switch :checked=\"settings.hideZeroStock\" @change=\"(e)=>update('hideZeroStock', e.detail.value)\" />\r\n\t\t</view>\r\n\t\t<view class=\"item\">\r\n\t\t\t<text>隐藏进货价</text>\r\n\t\t\t<switch :checked=\"settings.hidePurchasePrice\" @change=\"(e)=>update('hidePurchasePrice', e.detail.value)\" />\r\n\t\t</view>\r\n\t</view>\r\n</template>\r\n\r\n<script>\r\nimport { get, put } from '../../common/http.js'\r\n\r\nexport default {\r\n\tdata() {\r\n\t\treturn { settings: { hideZeroStock: false, hidePurchasePrice: false } }\r\n\t},\r\n\tonLoad() { this.load() },\r\n\tmethods: {\r\n\t\tasync load() {\r\n\t\t\ttry {\r\n\t\t\t\tconst res = await get('/api/product-settings')\r\n\t\t\t\tthis.settings = { hideZeroStock: !!res?.hideZeroStock, hidePurchasePrice: !!res?.hidePurchasePrice }\r\n\t\t\t} catch (_) {}\r\n\t\t},\r\n\t\tasync update(key, val) {\r\n\t\t\tconst next = { ...this.settings, [key]: val }\r\n\t\t\tthis.settings = next\r\n\t\t\ttry { await put('/api/product-settings', next) } catch (_) {}\r\n\t\t}\r\n\t}\r\n}\r\n</script>\r\n\r\n<style>\r\n.page { background:#fff; }\r\n.item { display:flex; justify-content: space-between; align-items:center; padding: 20rpx; border-bottom: 1rpx solid #f1f1f1; }\r\n</style>\r\n\r\n\r\n\r\n\r\n","import MiniProgramPage from 'C:/Users/21826/Desktop/Wj/PartsInquiry/frontend/pages/product/settings.vue'\nwx.createPage(MiniProgramPage)"],"names":["get","put"],"mappings":";;;AAgBA,MAAK,YAAU;AAAA,EACd,OAAO;AACN,WAAO,EAAE,UAAU,EAAE,eAAe,OAAO,mBAAmB,QAAQ;AAAA,EACtE;AAAA,EACD,SAAS;AAAE,SAAK;EAAQ;AAAA,EACxB,SAAS;AAAA,IACR,MAAM,OAAO;AACZ,UAAI;AACH,cAAM,MAAM,MAAMA,YAAG,IAAC,uBAAuB;AAC7C,aAAK,WAAW,EAAE,eAAe,CAAC,EAAC,2BAAK,gBAAe,mBAAmB,CAAC,EAAC,2BAAK,mBAAkB;AAAA,eAC3F,GAAG;AAAA,MAAC;AAAA,IACb;AAAA,IACD,MAAM,OAAO,KAAK,KAAK;AACtB,YAAM,OAAO,EAAE,GAAG,KAAK,UAAU,CAAC,GAAG,GAAG,IAAI;AAC5C,WAAK,WAAW;AAChB,UAAI;AAAE,cAAMC,YAAAA,IAAI,yBAAyB,IAAI;AAAA,MAAI,SAAO,GAAG;AAAA,MAAC;AAAA,IAC7D;AAAA,EACD;AACD;;;;;;;;;;ACjCA,GAAG,WAAW,eAAe;"}

View File

@@ -0,0 +1 @@
{"version":3,"file":"units.js","sources":["pages/product/units.vue","../../../../Downloads/HBuilderX.4.76.2025082103/HBuilderX/plugins/uniapp-cli-vite/uniPage:/cGFnZXMvcHJvZHVjdC91bml0cy52dWU"],"sourcesContent":["<template>\r\n\t<view class=\"page\">\r\n\t\t<view class=\"toolbar\">\r\n\t\t\t<input v-model.trim=\"name\" placeholder=\"新单位名称\" />\r\n\t\t\t<button size=\"mini\" @click=\"create\">新增</button>\r\n\t\t</view>\r\n\t\t<scroll-view scroll-y class=\"list\">\r\n\t\t\t<view class=\"item\" v-for=\"u in list\" :key=\"u.id\">\r\n\t\t\t\t<input v-model.trim=\"u.name\" />\r\n\t\t\t\t<view class=\"ops\">\r\n\t\t\t\t\t<button size=\"mini\" @click=\"update(u)\">保存</button>\r\n\t\t\t\t\t<button size=\"mini\" type=\"warn\" @click=\"remove(u)\">删除</button>\r\n\t\t\t\t</view>\r\n\t\t\t</view>\r\n\t\t</scroll-view>\r\n\t</view>\r\n</template>\r\n\r\n<script>\r\nimport { get, post, put, del } from '../../common/http.js'\r\n\r\nexport default {\r\n\tdata() {\r\n\t\treturn { name: '', list: [] }\r\n\t},\r\n\tonLoad() { this.reload() },\r\n\tmethods: {\r\n\t\tasync reload() {\r\n\t\t\ttry {\r\n\t\t\t\tconst res = await get('/api/product-units')\r\n\t\t\t\tthis.list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])\r\n\t\t\t} catch (_) {}\r\n\t\t},\r\n\t\tasync create() {\r\n\t\t\tif (!this.name) return\r\n\t\t\tawait post('/api/product-units', { name: this.name })\r\n\t\t\tthis.name = ''\r\n\t\t\tthis.reload()\r\n\t\t},\r\n\t\tasync update(u) {\r\n\t\t\tawait put('/api/product-units/' + u.id, { name: u.name })\r\n\t\t\tuni.showToast({ title: '已保存', icon: 'success' })\r\n\t\t},\r\n\t\tasync remove(u) {\r\n\t\t\tuni.showModal({ content: '确定删除该单位?', success: async (r) => {\r\n\t\t\t\tif (!r.confirm) return\r\n\t\t\t\tawait del('/api/product-units/' + u.id)\r\n\t\t\t\tthis.reload()\r\n\t\t\t}})\r\n\t\t}\r\n\t}\r\n}\r\n</script>\r\n\r\n<style>\r\n.page { display:flex; flex-direction: column; height: 100vh; }\r\n.toolbar { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; }\r\n.toolbar input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }\r\n.list { flex:1; }\r\n.item { display:flex; gap: 12rpx; align-items:center; padding: 16rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }\r\n.item input { flex:1; background:#f7f7f7; border-radius: 10rpx; padding: 12rpx; }\r\n.ops { display:flex; gap: 10rpx; }\r\n</style>\r\n\r\n\r\n\r\n\r\n","import MiniProgramPage from 'C:/Users/21826/Desktop/Wj/PartsInquiry/frontend/pages/product/units.vue'\nwx.createPage(MiniProgramPage)"],"names":["get","post","put","uni","del"],"mappings":";;;AAqBA,MAAK,YAAU;AAAA,EACd,OAAO;AACN,WAAO,EAAE,MAAM,IAAI,MAAM,CAAA,EAAG;AAAA,EAC5B;AAAA,EACD,SAAS;AAAE,SAAK;EAAU;AAAA,EAC1B,SAAS;AAAA,IACR,MAAM,SAAS;AACd,UAAI;AACH,cAAM,MAAM,MAAMA,YAAG,IAAC,oBAAoB;AAC1C,aAAK,OAAO,MAAM,QAAQ,2BAAK,IAAI,IAAI,IAAI,OAAQ,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAA;AAAA,eACtE,GAAG;AAAA,MAAC;AAAA,IACb;AAAA,IACD,MAAM,SAAS;AACd,UAAI,CAAC,KAAK;AAAM;AAChB,YAAMC,YAAAA,KAAK,sBAAsB,EAAE,MAAM,KAAK,KAAG,CAAG;AACpD,WAAK,OAAO;AACZ,WAAK,OAAO;AAAA,IACZ;AAAA,IACD,MAAM,OAAO,GAAG;AACf,YAAMC,YAAG,IAAC,wBAAwB,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM;AACxDC,oBAAG,MAAC,UAAU,EAAE,OAAO,OAAO,MAAM,WAAW;AAAA,IAC/C;AAAA,IACD,MAAM,OAAO,GAAG;AACfA,oBAAG,MAAC,UAAU,EAAE,SAAS,YAAY,SAAS,OAAO,MAAM;AAC1D,YAAI,CAAC,EAAE;AAAS;AAChB,cAAMC,gBAAI,wBAAwB,EAAE,EAAE;AACtC,aAAK,OAAO;AAAA,MACb,EAAC,CAAC;AAAA,IACH;AAAA,EACD;AACD;;;;;;;;;;;;;;;;;;;;;;AClDA,GAAG,WAAW,eAAe;"}

View File

@@ -0,0 +1 @@
{"version":3,"file":"select.js","sources":["pages/supplier/select.vue","../../../../Downloads/HBuilderX.4.76.2025082103/HBuilderX/plugins/uniapp-cli-vite/uniPage:/cGFnZXMvc3VwcGxpZXIvc2VsZWN0LnZ1ZQ"],"sourcesContent":["<template>\r\n\t<view class=\"page\">\r\n\t\t<view class=\"search\">\r\n\t\t\t<input v-model=\"kw\" placeholder=\"搜索供应商名称/电话\" @confirm=\"search\" />\r\n\t\t\t<button size=\"mini\" @click=\"search\">搜索</button>\r\n\t\t</view>\r\n\t\t<scroll-view scroll-y class=\"list\">\r\n\t\t\t<view class=\"item\" v-for=\"s in suppliers\" :key=\"s.id\" @click=\"select(s)\">\r\n\t\t\t\t<view class=\"name\">{{ s.name }}</view>\r\n\t\t\t\t<view class=\"meta\">{{ s.mobile || '—' }}</view>\r\n\t\t\t</view>\r\n\t\t</scroll-view>\r\n\t</view>\r\n</template>\r\n\r\n<script>\r\n\timport { get } from '../../common/http.js'\r\n\texport default {\r\n\t\tdata() { return { kw: '', suppliers: [] } },\r\n\t\tonLoad() { this.search() },\r\n\t\tmethods: {\r\n\t\t\tasync search() {\r\n\t\t\t\ttry {\r\n\t\t\t\t\tconst res = await get('/api/suppliers', { kw: this.kw, page: 1, size: 50 })\r\n\t\t\t\t\tthis.suppliers = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])\r\n\t\t\t\t} catch(e) { uni.showToast({ title: '加载失败', icon: 'none' }) }\r\n\t\t\t},\r\n\t\t\tselect(s) {\r\n\t\t\t\tconst opener = getCurrentPages()[getCurrentPages().length-2]\r\n\t\t\t\tif (opener && opener.$vm) {\r\n\t\t\t\t\topener.$vm.order.supplierId = s.id\r\n\t\t\t\t\topener.$vm.supplierName = s.name\r\n\t\t\t\t}\r\n\t\t\t\tuni.navigateBack()\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n</script>\r\n\r\n<style>\r\n\t.page { display:flex; flex-direction: column; height: 100vh; }\r\n\t.search { display:flex; gap: 12rpx; padding: 16rpx; background:#fff; }\r\n\t.search input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx; }\r\n\t.list { flex:1; }\r\n\t.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1; }\r\n\t.name { color:#333; margin-bottom: 6rpx; }\r\n\t.meta { color:#888; font-size: 24rpx; }\r\n</style>\r\n\r\n\r\n\r\n","import MiniProgramPage from 'C:/Users/21826/Desktop/Wj/PartsInquiry/frontend/pages/supplier/select.vue'\nwx.createPage(MiniProgramPage)"],"names":["get","uni"],"mappings":";;;AAiBC,MAAK,YAAU;AAAA,EACd,OAAO;AAAE,WAAO,EAAE,IAAI,IAAI,WAAW,CAAA;EAAM;AAAA,EAC3C,SAAS;AAAE,SAAK;EAAU;AAAA,EAC1B,SAAS;AAAA,IACR,MAAM,SAAS;AACd,UAAI;AACH,cAAM,MAAM,MAAMA,gBAAI,kBAAkB,EAAE,IAAI,KAAK,IAAI,MAAM,GAAG,MAAM,GAAC,CAAG;AAC1E,aAAK,YAAY,MAAM,QAAQ,2BAAK,IAAI,IAAI,IAAI,OAAQ,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAA;AAAA,eAC5E,GAAG;AAAEC,sBAAAA,MAAI,UAAU,EAAE,OAAO,QAAQ,MAAM,OAAO,CAAC;AAAA,MAAE;AAAA,IAC5D;AAAA,IACD,OAAO,GAAG;AACT,YAAM,SAAS,gBAAiB,EAAC,gBAAe,EAAG,SAAO,CAAC;AAC3D,UAAI,UAAU,OAAO,KAAK;AACzB,eAAO,IAAI,MAAM,aAAa,EAAE;AAChC,eAAO,IAAI,eAAe,EAAE;AAAA,MAC7B;AACAA,oBAAAA,MAAI,aAAa;AAAA,IAClB;AAAA,EACD;AACD;;;;;;;;;;;;;;;;;;ACnCD,GAAG,WAAW,eAAe;"}

View File

@@ -3,6 +3,16 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const common_vendor = require("./common/vendor.js");
if (!Math) {
"./pages/index/index.js";
"./pages/order/create.js";
"./pages/product/select.js";
"./pages/product/list.js";
"./pages/product/form.js";
"./pages/product/categories.js";
"./pages/product/units.js";
"./pages/product/settings.js";
"./pages/customer/select.js";
"./pages/supplier/select.js";
"./pages/account/select.js";
}
const _sfc_main = {
onLaunch: function() {

View File

@@ -1,6 +1,16 @@
{
"pages": [
"pages/index/index"
"pages/index/index",
"pages/order/create",
"pages/product/select",
"pages/product/list",
"pages/product/form",
"pages/product/categories",
"pages/product/units",
"pages/product/settings",
"pages/customer/select",
"pages/supplier/select",
"pages/account/select"
],
"window": {
"navigationBarTextStyle": "black",

View File

@@ -1,4 +1,6 @@
"use strict";
const _imports_0 = "/static/metal-bg.jpg";
exports._imports_0 = _imports_0;
const _imports_0$1 = "/static/metal-bg.jpg";
const _imports_0 = "/static/logo.png";
exports._imports_0 = _imports_0$1;
exports._imports_0$1 = _imports_0;
//# sourceMappingURL=../../.sourcemap/mp-weixin/common/assets.js.map

View File

@@ -0,0 +1,23 @@
"use strict";
const common_vendor = require("./vendor.js");
const envBaseUrl = typeof process !== "undefined" && process.env && (process.env.VITE_APP_API_BASE_URL || process.env.API_BASE_URL) || "";
const storageBaseUrl = typeof common_vendor.index !== "undefined" ? common_vendor.index.getStorageSync("API_BASE_URL") || "" : "";
const fallbackBaseUrl = "http://192.168.31.193:8080";
const API_BASE_URL = (envBaseUrl || storageBaseUrl || fallbackBaseUrl).replace(/\/$/, "");
const candidateBases = [envBaseUrl, storageBaseUrl, fallbackBaseUrl, "http://127.0.0.1:8080", "http://localhost:8080"];
const API_BASE_URL_CANDIDATES = Array.from(new Set(candidateBases.filter(Boolean))).map((u) => String(u).replace(/\/$/, ""));
const envShopId = typeof process !== "undefined" && process.env && (process.env.VITE_APP_SHOP_ID || process.env.SHOP_ID) || "";
const storageShopId = typeof common_vendor.index !== "undefined" ? common_vendor.index.getStorageSync("SHOP_ID") || "" : "";
const SHOP_ID = Number(envShopId || storageShopId || 1);
const envEnableDefaultUser = typeof process !== "undefined" && process.env && (process.env.VITE_APP_ENABLE_DEFAULT_USER || process.env.ENABLE_DEFAULT_USER) || "";
const storageEnableDefaultUser = typeof common_vendor.index !== "undefined" ? common_vendor.index.getStorageSync("ENABLE_DEFAULT_USER") || "" : "";
const ENABLE_DEFAULT_USER = String(envEnableDefaultUser || storageEnableDefaultUser || "true").toLowerCase() === "true";
const envDefaultUserId = typeof process !== "undefined" && process.env && (process.env.VITE_APP_DEFAULT_USER_ID || process.env.DEFAULT_USER_ID) || "";
const storageDefaultUserId = typeof common_vendor.index !== "undefined" ? common_vendor.index.getStorageSync("DEFAULT_USER_ID") || "" : "";
const DEFAULT_USER_ID = Number(envDefaultUserId || storageDefaultUserId || 2);
exports.API_BASE_URL = API_BASE_URL;
exports.API_BASE_URL_CANDIDATES = API_BASE_URL_CANDIDATES;
exports.DEFAULT_USER_ID = DEFAULT_USER_ID;
exports.ENABLE_DEFAULT_USER = ENABLE_DEFAULT_USER;
exports.SHOP_ID = SHOP_ID;
//# sourceMappingURL=../../.sourcemap/mp-weixin/common/config.js.map

View File

@@ -0,0 +1,18 @@
"use strict";
const INCOME_CATEGORIES = [
{ key: "sale_income", label: "销售收入" },
{ key: "operation_income", label: "经营所得" },
{ key: "interest_income", label: "利息收入" },
{ key: "investment_income", label: "投资收入" },
{ key: "other_income", label: "其它收入" }
];
const EXPENSE_CATEGORIES = [
{ key: "operation_expense", label: "经营支出" },
{ key: "office_supplies", label: "办公用品" },
{ key: "rent", label: "房租" },
{ key: "interest_expense", label: "利息支出" },
{ key: "other_expense", label: "其它支出" }
];
exports.EXPENSE_CATEGORIES = EXPENSE_CATEGORIES;
exports.INCOME_CATEGORIES = INCOME_CATEGORIES;
//# sourceMappingURL=../../.sourcemap/mp-weixin/common/constants.js.map

View File

@@ -0,0 +1,104 @@
"use strict";
const common_vendor = require("./vendor.js");
const common_config = require("./config.js");
function buildUrl(path) {
if (!path)
return common_config.API_BASE_URL;
if (path.startsWith("http"))
return path;
return common_config.API_BASE_URL + (path.startsWith("/") ? path : "/" + path);
}
function requestWithFallback(options, candidates, idx, resolve, reject) {
const base = candidates[idx] || common_config.API_BASE_URL;
const url = options.url.replace(/^https?:\/\/[^/]+/, base);
common_vendor.index.request({ ...options, url, success: (res) => {
const { statusCode, data } = res;
if (statusCode >= 200 && statusCode < 300)
return resolve(data);
if (idx + 1 < candidates.length)
return requestWithFallback(options, candidates, idx + 1, resolve, reject);
reject(new Error("HTTP " + statusCode));
}, fail: (err) => {
if (idx + 1 < candidates.length)
return requestWithFallback(options, candidates, idx + 1, resolve, reject);
reject(err);
} });
}
function get(path, params = {}) {
return new Promise((resolve, reject) => {
const headers = { "X-Shop-Id": common_config.SHOP_ID };
if (common_config.ENABLE_DEFAULT_USER && common_config.DEFAULT_USER_ID)
headers["X-User-Id"] = common_config.DEFAULT_USER_ID;
const options = { url: buildUrl(path), method: "GET", data: params, header: headers };
requestWithFallback(options, common_config.API_BASE_URL_CANDIDATES, 0, resolve, reject);
});
}
function post(path, body = {}) {
return new Promise((resolve, reject) => {
const headers = { "Content-Type": "application/json", "X-Shop-Id": common_config.SHOP_ID };
if (common_config.ENABLE_DEFAULT_USER && common_config.DEFAULT_USER_ID)
headers["X-User-Id"] = common_config.DEFAULT_USER_ID;
const options = { url: buildUrl(path), method: "POST", data: body, header: headers };
requestWithFallback(options, common_config.API_BASE_URL_CANDIDATES, 0, resolve, reject);
});
}
function put(path, body = {}) {
return new Promise((resolve, reject) => {
const headers = { "Content-Type": "application/json", "X-Shop-Id": common_config.SHOP_ID };
if (common_config.ENABLE_DEFAULT_USER && common_config.DEFAULT_USER_ID)
headers["X-User-Id"] = common_config.DEFAULT_USER_ID;
const options = { url: buildUrl(path), method: "PUT", data: body, header: headers };
requestWithFallback(options, common_config.API_BASE_URL_CANDIDATES, 0, resolve, reject);
});
}
function del(path, body = {}) {
return new Promise((resolve, reject) => {
const headers = { "Content-Type": "application/json", "X-Shop-Id": common_config.SHOP_ID };
if (common_config.ENABLE_DEFAULT_USER && common_config.DEFAULT_USER_ID)
headers["X-User-Id"] = common_config.DEFAULT_USER_ID;
const options = { url: buildUrl(path), method: "DELETE", data: body, header: headers };
requestWithFallback(options, common_config.API_BASE_URL_CANDIDATES, 0, resolve, reject);
});
}
function uploadWithFallback(options, candidates, idx, resolve, reject) {
const base = candidates[idx] || common_config.API_BASE_URL;
const url = options.url.replace(/^https?:\/\/[^/]+/, base);
const uploadOptions = { ...options, url };
common_vendor.index.uploadFile({
...uploadOptions,
success: (res) => {
const statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
try {
const data = typeof res.data === "string" ? JSON.parse(res.data) : res.data;
return resolve(data);
} catch (e) {
return resolve(res.data);
}
}
if (idx + 1 < candidates.length)
return uploadWithFallback(options, candidates, idx + 1, resolve, reject);
reject(new Error("HTTP " + statusCode));
},
fail: (err) => {
if (idx + 1 < candidates.length)
return uploadWithFallback(options, candidates, idx + 1, resolve, reject);
reject(err);
}
});
}
function upload(path, filePath, formData = {}, name = "file") {
return new Promise((resolve, reject) => {
const header = { "X-Shop-Id": common_config.SHOP_ID };
if (common_config.ENABLE_DEFAULT_USER && common_config.DEFAULT_USER_ID)
header["X-User-Id"] = common_config.DEFAULT_USER_ID;
const options = { url: buildUrl(path), filePath, name, formData, header };
uploadWithFallback(options, common_config.API_BASE_URL_CANDIDATES, 0, resolve, reject);
});
}
exports.del = del;
exports.get = get;
exports.post = post;
exports.put = put;
exports.upload = upload;
//# sourceMappingURL=../../.sourcemap/mp-weixin/common/http.js.map

View File

@@ -68,8 +68,8 @@ const capitalize = cacheStringFunction((str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
});
const toHandlerKey = cacheStringFunction((str) => {
const s = str ? `on${capitalize(str)}` : ``;
return s;
const s2 = str ? `on${capitalize(str)}` : ``;
return s2;
});
const hasChanged = (value, oldValue) => !Object.is(value, oldValue);
const invokeArrayFns$1 = (fns, arg) => {
@@ -88,6 +88,40 @@ const looseToNumber = (val) => {
const n = parseFloat(val);
return isNaN(n) ? val : n;
};
const toNumber = (val) => {
const n = isString(val) ? Number(val) : NaN;
return isNaN(n) ? val : n;
};
function normalizeStyle(value) {
if (isArray(value)) {
const res = {};
for (let i = 0; i < value.length; i++) {
const item = value[i];
const normalized = isString(item) ? parseStringStyle(item) : normalizeStyle(item);
if (normalized) {
for (const key in normalized) {
res[key] = normalized[key];
}
}
}
return res;
} else if (isString(value) || isObject(value)) {
return value;
}
}
const listDelimiterRE = /;(?![^(]*\))/g;
const propertyDelimiterRE = /:([^]+)/;
const styleCommentRE = /\/\*[^]*?\*\//g;
function parseStringStyle(cssText) {
const ret = {};
cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => {
if (item) {
const tmp = item.split(propertyDelimiterRE);
tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim());
}
});
return ret;
}
const toDisplayString = (val) => {
return isString(val) ? val : val == null ? "" : isArray(val) || isObject(val) && (val.toString === objectToString || !isFunction(val.toString)) ? JSON.stringify(val, replacer, 2) : String(val);
};
@@ -1283,6 +1317,9 @@ function isReadonly(value) {
function isShallow(value) {
return !!(value && value["__v_isShallow"]);
}
function isProxy(value) {
return isReactive(value) || isReadonly(value);
}
function toRaw(observed) {
const raw = observed && observed["__v_raw"];
return raw ? toRaw(raw) : observed;
@@ -2074,6 +2111,47 @@ function setCurrentRenderingInstance(instance) {
instance && instance.type.__scopeId || null;
return prev;
}
const COMPONENTS = "components";
function resolveComponent(name, maybeSelfReference) {
return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name;
}
function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) {
const instance = currentRenderingInstance || currentInstance;
if (instance) {
const Component2 = instance.type;
if (type === COMPONENTS) {
const selfName = getComponentName(
Component2,
false
);
if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) {
return Component2;
}
}
const res = (
// local registration
// check instance[type] first which is resolved for options API
resolve(instance[type] || Component2[type], name) || // global registration
resolve(instance.appContext[type], name)
);
if (!res && maybeSelfReference) {
return Component2;
}
if (warnMissing && !res) {
const extra = type === COMPONENTS ? `
If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.` : ``;
warn$1(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`);
}
return res;
} else {
warn$1(
`resolve${capitalize(type.slice(0, -1))} can only be used in render() or setup().`
);
}
}
function resolve(registry, name) {
return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]);
}
const INITIAL_WATCHER_VALUE = {};
function watch(source, cb, options) {
if (!isFunction(cb)) {
@@ -3688,6 +3766,12 @@ const Static = Symbol.for("v-stc");
function isVNode(value) {
return value ? value.__v_isVNode === true : false;
}
const InternalObjectKey = `__vInternal`;
function guardReactiveProps(props) {
if (!props)
return null;
return isProxy(props) || InternalObjectKey in props ? extend({}, props) : props;
}
const emptyAppContext = createAppContext();
let uid = 0;
function createComponentInstance(vnode, parent, suspense) {
@@ -4928,6 +5012,11 @@ function initApp(app) {
}
}
const propsCaches = /* @__PURE__ */ Object.create(null);
function renderProps(props) {
const { uid: uid2, __counter } = getCurrentInstance();
const propsId = (propsCaches[uid2] || (propsCaches[uid2] = [])).push(guardReactiveProps(props)) - 1;
return uid2 + "," + propsId + "," + __counter;
}
function pruneComponentPropsCache(uid2) {
delete propsCaches[uid2];
}
@@ -4968,6 +5057,22 @@ function getCreateApp() {
return my[method];
}
}
function stringifyStyle(value) {
if (isString(value)) {
return value;
}
return stringify(normalizeStyle(value));
}
function stringify(styles) {
let ret = "";
if (!styles || isString(styles)) {
return ret;
}
for (const key in styles) {
ret += `${key.startsWith(`--`) ? key : hyphenate(key)}:${styles[key]};`;
}
return ret;
}
function vOn(value, key) {
const instance = getCurrentInstance();
const ctx = instance.ctx;
@@ -5094,10 +5199,34 @@ function vFor(source, renderItem) {
}
return ret;
}
function withModelModifiers(fn, { number, trim }, isComponent = false) {
if (isComponent) {
return (...args) => {
if (trim) {
args = args.map((a) => a.trim());
} else if (number) {
args = args.map(toNumber);
}
return fn(...args);
};
}
return (event) => {
const value = event.detail.value;
if (trim) {
event.detail.value = value.trim();
} else if (number) {
event.detail.value = toNumber(value);
}
return fn(event);
};
}
const o = (value, key) => vOn(value, key);
const f = (source, renderItem) => vFor(source, renderItem);
const s = (value) => stringifyStyle(value);
const e = (target, ...sources) => extend(target, ...sources);
const t = (val) => toDisplayString(val);
const p = (props) => renderProps(props);
const m = (fn, modifiers, isComponent = false) => withModelModifiers(fn, modifiers, isComponent);
function createApp$1(rootComponent, rootProps = null) {
rootComponent && (rootComponent.mpType = "app");
return createVueApp(rootComponent, rootProps).use(plugin);
@@ -5419,8 +5548,8 @@ function promisify$1(name, fn) {
if (hasCallback(args)) {
return wrapperReturnValue(name, invokeApi(name, fn, extend({}, args), rest));
}
return wrapperReturnValue(name, handlePromise(new Promise((resolve, reject) => {
invokeApi(name, fn, extend({}, args, { success: resolve, fail: reject }), rest);
return wrapperReturnValue(name, handlePromise(new Promise((resolve2, reject) => {
invokeApi(name, fn, extend({}, args, { success: resolve2, fail: reject }), rest);
})));
};
}
@@ -5741,7 +5870,7 @@ function invokeGetPushCidCallbacks(cid2, errMsg) {
getPushCidCallbacks.length = 0;
}
const API_GET_PUSH_CLIENT_ID = "getPushClientId";
const getPushClientId = defineAsyncApi(API_GET_PUSH_CLIENT_ID, (_, { resolve, reject }) => {
const getPushClientId = defineAsyncApi(API_GET_PUSH_CLIENT_ID, (_, { resolve: resolve2, reject }) => {
Promise.resolve().then(() => {
if (typeof enabled === "undefined") {
enabled = false;
@@ -5750,7 +5879,7 @@ const getPushClientId = defineAsyncApi(API_GET_PUSH_CLIENT_ID, (_, { resolve, re
}
getPushCidCallbacks.push((cid2, errMsg) => {
if (cid2) {
resolve({ cid: cid2 });
resolve2({ cid: cid2 });
} else {
reject(errMsg);
}
@@ -5819,9 +5948,9 @@ function promisify(name, api) {
if (isFunction(options.success) || isFunction(options.fail) || isFunction(options.complete)) {
return wrapperReturnValue(name, invokeApi(name, api, extend({}, options), rest));
}
return wrapperReturnValue(name, handlePromise(new Promise((resolve, reject) => {
return wrapperReturnValue(name, handlePromise(new Promise((resolve2, reject) => {
invokeApi(name, api, extend({}, options, {
success: resolve,
success: resolve2,
fail: reject
}), rest);
})));
@@ -6428,13 +6557,13 @@ function initRuntimeSocket(hosts, port, id) {
}
const SOCKET_TIMEOUT = 500;
function tryConnectSocket(host2, port, id) {
return new Promise((resolve, reject) => {
return new Promise((resolve2, reject) => {
const socket = index.connectSocket({
url: `ws://${host2}:${port}/${id}`,
multiple: true,
// 支付宝小程序 是否开启多实例
fail() {
resolve(null);
resolve2(null);
}
});
const timer = setTimeout(() => {
@@ -6442,19 +6571,19 @@ function tryConnectSocket(host2, port, id) {
code: 1006,
reason: "connect timeout"
});
resolve(null);
resolve2(null);
}, SOCKET_TIMEOUT);
socket.onOpen((e2) => {
clearTimeout(timer);
resolve(socket);
resolve2(socket);
});
socket.onClose((e2) => {
clearTimeout(timer);
resolve(null);
resolve2(null);
});
socket.onError((e2) => {
clearTimeout(timer);
resolve(null);
resolve2(null);
});
});
}
@@ -6917,9 +7046,9 @@ function isConsoleWritable() {
return isWritable;
}
function initRuntimeSocketService() {
const hosts = "198.18.0.1,192.168.31.107,127.0.0.1";
const hosts = "198.18.0.1,192.168.31.193,127.0.0.1";
const port = "8090";
const id = "mp-weixin_BJ7qAd";
const id = "mp-weixin_UjOtWQ";
const lazy = typeof swan !== "undefined";
let restoreError = lazy ? () => {
} : initOnError();
@@ -7870,6 +7999,10 @@ exports.createSSRApp = createSSRApp;
exports.e = e;
exports.f = f;
exports.index = index;
exports.m = m;
exports.o = o;
exports.p = p;
exports.resolveComponent = resolveComponent;
exports.s = s;
exports.t = t;
//# sourceMappingURL=../../.sourcemap/mp-weixin/common/vendor.js.map

View File

@@ -0,0 +1,144 @@
"use strict";
const common_vendor = require("../common/vendor.js");
const common_http = require("../common/http.js");
const ITEM_SIZE = 210;
const GAP = 18;
const COLS = 3;
function px(rpx) {
return rpx;
}
const _sfc_main = {
name: "ImageUploader",
props: {
modelValue: { type: Array, default: () => [] },
max: { type: Number, default: 9 },
uploadPath: { type: String, default: "/api/attachments" },
uploadFieldName: { type: String, default: "file" },
formData: { type: Object, default: () => ({ ownerType: "product" }) }
},
data() {
return {
innerList: []
};
},
computed: {
areaHeight() {
const rows = Math.ceil((this.innerList.length + 1) / COLS) || 1;
return rows * ITEM_SIZE + (rows - 1) * GAP;
}
},
watch: {
modelValue: {
immediate: true,
handler(list) {
const mapped = (list || []).map((u, i) => ({
uid: String(i) + "_" + (u.id || u.url || Math.random().toString(36).slice(2)),
url: typeof u === "string" ? u : u.url || "",
x: this.posOf(i).x,
y: this.posOf(i).y
}));
this.innerList = mapped;
}
}
},
methods: {
posOf(index) {
const row = Math.floor(index / COLS);
const col = index % COLS;
return { x: px(col * (ITEM_SIZE + GAP)), y: px(row * (ITEM_SIZE + GAP)) };
},
cellStyle(index) {
return {
width: ITEM_SIZE + "rpx",
height: ITEM_SIZE + "rpx"
};
},
preview(index) {
common_vendor.index.previewImage({ urls: this.innerList.map((i) => i.url), current: index });
},
remove(index) {
this.innerList.splice(index, 1);
this.reflow();
this.emit();
},
choose() {
const remain = this.max - this.innerList.length;
if (remain <= 0)
return;
common_vendor.index.chooseImage({ count: remain, success: async (res) => {
for (const path of res.tempFilePaths) {
await this.doUpload(path);
}
} });
},
async doUpload(filePath) {
var _a;
try {
const resp = await common_http.upload(this.uploadPath, filePath, this.formData, this.uploadFieldName);
const url = (resp == null ? void 0 : resp.url) || ((_a = resp == null ? void 0 : resp.data) == null ? void 0 : _a.url) || (resp == null ? void 0 : resp.path) || "";
if (!url)
throw new Error("上传响应无 url");
this.innerList.push({ uid: Math.random().toString(36).slice(2), url, ...this.posOf(this.innerList.length) });
this.reflow();
this.emit();
} catch (e) {
common_vendor.index.showToast({ title: "上传失败", icon: "none" });
}
},
onMoving(index, e) {
const { x, y } = e.detail;
this.innerList[index].x = x;
this.innerList[index].y = y;
},
onMoveEnd(index) {
const mv = this.innerList[index];
const col = Math.round(mv.x / (ITEM_SIZE + GAP));
const row = Math.round(mv.y / (ITEM_SIZE + GAP));
let newIndex = row * COLS + col;
newIndex = Math.max(0, Math.min(newIndex, this.innerList.length - 1));
if (newIndex !== index) {
const moved = this.innerList.splice(index, 1)[0];
this.innerList.splice(newIndex, 0, moved);
}
this.reflow();
this.emit();
},
reflow() {
this.innerList.forEach((it, i) => {
const p = this.posOf(i);
it.x = p.x;
it.y = p.y;
});
},
emit() {
this.$emit("update:modelValue", this.innerList.map((i) => i.url));
this.$emit("change", this.innerList.map((i) => i.url));
}
}
};
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return common_vendor.e({
a: common_vendor.f($data.innerList, (img, index, i0) => {
return {
a: img.url,
b: common_vendor.o(($event) => $options.preview(index), img.uid),
c: common_vendor.o(($event) => $options.remove(index), img.uid),
d: img.uid,
e: common_vendor.s($options.cellStyle(index)),
f: img.x,
g: img.y,
h: common_vendor.o(($event) => $options.onMoving(index, $event), img.uid),
i: common_vendor.o(($event) => $options.onMoveEnd(index), img.uid)
};
}),
b: $data.innerList.length < $props.max
}, $data.innerList.length < $props.max ? {
c: common_vendor.o((...args) => $options.choose && $options.choose(...args))
} : {}, {
d: $options.areaHeight + "rpx",
e: $options.areaHeight + "rpx"
});
}
const Component = /* @__PURE__ */ common_vendor._export_sfc(_sfc_main, [["render", _sfc_render]]);
wx.createComponent(Component);
//# sourceMappingURL=../../.sourcemap/mp-weixin/components/ImageUploader.js.map

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1 @@
<view class="uploader"><view class="grid" style="{{'height:' + e}}"><movable-area class="area" style="{{'height:' + d}}"><movable-view wx:for="{{a}}" wx:for-item="img" wx:key="d" class="cell" style="{{img.e}}" direction="{{'all'}}" damping="{{40}}" friction="{{2}}" x="{{img.f}}" y="{{img.g}}" bindchange="{{img.h}}" bindtouchend="{{img.i}}"><image src="{{img.a}}" mode="aspectFill" class="thumb" bindtap="{{img.b}}"/><view class="remove" catchtap="{{img.c}}">×</view></movable-view><view wx:if="{{b}}" class="adder" bindtap="{{c}}"><text></text></view></movable-area></view></view>

View File

@@ -0,0 +1,15 @@
.uploader { padding: 12rpx; background: #fff;
}
.grid { position: relative;
}
.area { width: 100%; position: relative;
}
.cell { position: absolute; border-radius: 12rpx; overflow: hidden; box-shadow: 0 0 1rpx rgba(0,0,0,0.08);
}
.thumb { width: 100%; height: 100%;
}
.remove { position: absolute; right: 6rpx; top: 6rpx; background: rgba(0,0,0,0.45); color: #fff; width: 40rpx; height: 40rpx; text-align: center; line-height: 40rpx; border-radius: 20rpx; font-size: 28rpx;
}
.adder { width: 210rpx; height: 210rpx; border: 2rpx dashed #ccc; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; color: #999; position: absolute; left: 0; top: 0;
}

View File

@@ -0,0 +1,47 @@
"use strict";
const common_vendor = require("../../common/vendor.js");
const common_http = require("../../common/http.js");
const TYPE_MAP = { cash: "现金", bank: "银行", alipay: "支付宝", wechat: "微信", other: "其他" };
const _sfc_main = {
data() {
return { accounts: [] };
},
async onLoad() {
try {
const res = await common_http.get("/api/accounts");
this.accounts = Array.isArray(res) ? res : (res == null ? void 0 : res.list) || [];
} catch (e) {
common_vendor.index.showToast({ title: "加载失败", icon: "none" });
}
},
methods: {
select(a) {
const opener = getCurrentPages()[getCurrentPages().length - 2];
if (opener && opener.$vm) {
opener.$vm.selectedAccountId = a.id;
opener.$vm.selectedAccountName = a.name;
}
common_vendor.index.navigateBack();
},
typeLabel(t) {
return TYPE_MAP[t] || t;
}
}
};
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return {
a: common_vendor.f($data.accounts, (a, k0, i0) => {
var _a;
return {
a: common_vendor.t(a.name),
b: common_vendor.t($options.typeLabel(a.type)),
c: common_vendor.t(((_a = a.balance) == null ? void 0 : _a.toFixed) ? a.balance.toFixed(2) : a.balance),
d: a.id,
e: common_vendor.o(($event) => $options.select(a), a.id)
};
})
};
}
const MiniProgramPage = /* @__PURE__ */ common_vendor._export_sfc(_sfc_main, [["render", _sfc_render]]);
wx.createPage(MiniProgramPage);
//# sourceMappingURL=../../../.sourcemap/mp-weixin/pages/account/select.js.map

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "选择账户",
"usingComponents": {}
}

View File

@@ -0,0 +1 @@
<view class="page"><scroll-view scroll-y class="list"><view wx:for="{{a}}" wx:for-item="a" wx:key="d" class="item" bindtap="{{a.e}}"><view class="name">{{a.a}}</view><view class="meta">{{a.b}} · 余额:{{a.c}}</view></view></scroll-view></view>

View File

@@ -0,0 +1,11 @@
.page { display:flex; flex-direction: column; height: 100vh;
}
.list { flex:1;
}
.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1;
}
.name { color:#333; margin-bottom: 6rpx;
}
.meta { color:#888; font-size: 24rpx;
}

View File

@@ -0,0 +1,48 @@
"use strict";
const common_vendor = require("../../common/vendor.js");
const common_http = require("../../common/http.js");
const _sfc_main = {
data() {
return { kw: "", customers: [] };
},
onLoad() {
this.search();
},
methods: {
async search() {
try {
const res = await common_http.get("/api/customers", { kw: this.kw, page: 1, size: 50 });
this.customers = Array.isArray(res == null ? void 0 : res.list) ? res.list : Array.isArray(res) ? res : [];
} catch (e) {
common_vendor.index.showToast({ title: "加载失败", icon: "none" });
}
},
select(c) {
const opener = getCurrentPages()[getCurrentPages().length - 2];
if (opener && opener.$vm) {
opener.$vm.order.customerId = c.id;
opener.$vm.customerName = c.name;
}
common_vendor.index.navigateBack();
}
}
};
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return {
a: common_vendor.o((...args) => $options.search && $options.search(...args)),
b: $data.kw,
c: common_vendor.o(($event) => $data.kw = $event.detail.value),
d: common_vendor.o((...args) => $options.search && $options.search(...args)),
e: common_vendor.f($data.customers, (c, k0, i0) => {
return {
a: common_vendor.t(c.name),
b: common_vendor.t(c.mobile || "—"),
c: c.id,
d: common_vendor.o(($event) => $options.select(c), c.id)
};
})
};
}
const MiniProgramPage = /* @__PURE__ */ common_vendor._export_sfc(_sfc_main, [["render", _sfc_render]]);
wx.createPage(MiniProgramPage);
//# sourceMappingURL=../../../.sourcemap/mp-weixin/pages/customer/select.js.map

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "选择客户",
"usingComponents": {}
}

View File

@@ -0,0 +1 @@
<view class="page"><view class="search"><input placeholder="搜索客户名称/电话" bindconfirm="{{a}}" value="{{b}}" bindinput="{{c}}"/><button size="mini" bindtap="{{d}}">搜索</button></view><scroll-view scroll-y class="list"><view wx:for="{{e}}" wx:for-item="c" wx:key="c" class="item" bindtap="{{c.d}}"><view class="name">{{c.a}}</view><view class="meta">{{c.b}}</view></view></scroll-view></view>

View File

@@ -0,0 +1,15 @@
.page { display:flex; flex-direction: column; height: 100vh;
}
.search { display:flex; gap: 12rpx; padding: 16rpx; background:#fff;
}
.search input { flex:1; background:#f6f6f6; border-radius: 12rpx; padding: 12rpx;
}
.list { flex:1;
}
.item { padding: 20rpx 24rpx; background:#fff; border-bottom: 1rpx solid #f1f1f1;
}
.name { color:#333; margin-bottom: 6rpx;
}
.meta { color:#888; font-size: 24rpx;
}

View File

@@ -0,0 +1,135 @@
"use strict";
const common_vendor = require("../../common/vendor.js");
const common_http = require("../../common/http.js");
function toDateString(d) {
const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
const _sfc_main = {
data() {
const today = /* @__PURE__ */ new Date();
const first = new Date(today.getFullYear(), today.getMonth(), 1);
return {
range: "month",
biz: "sale",
sub: "out",
kw: "",
begin: toDateString(first),
end: toDateString(today),
list: [],
loading: false
};
},
computed: {
totalAmount() {
return this.list.reduce((s, o) => s + Number(o.amount || 0), 0);
}
},
onLoad() {
this.reload();
},
methods: {
setRange(r) {
this.range = r;
const now = /* @__PURE__ */ new Date();
if (r === "today") {
this.begin = this.end = toDateString(now);
} else if (r === "week") {
const day = now.getDay() || 7;
const start = new Date(now);
start.setDate(now.getDate() - day + 1);
this.begin = toDateString(start);
this.end = toDateString(now);
} else if (r === "month") {
const first = new Date(now.getFullYear(), now.getMonth(), 1);
this.begin = toDateString(first);
this.end = toDateString(now);
} else if (r === "year") {
const first = new Date(now.getFullYear(), 0, 1);
this.begin = toDateString(first);
this.end = toDateString(now);
}
this.reload();
},
async reload() {
this.loading = true;
try {
const res = await common_http.get("/api/sales/orders", { begin: this.begin, end: this.end, kw: this.kw, sub: this.sub });
this.list = Array.isArray(res == null ? void 0 : res.list) ? res.list : Array.isArray(res) ? res : [];
} catch (e) {
this.list = [];
} finally {
this.loading = false;
}
},
goCreate() {
common_vendor.index.navigateTo({ url: "/pages/order/create" });
},
open(o) {
}
}
};
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return common_vendor.e({
a: $data.range === "custom" ? 1 : "",
b: common_vendor.o(($event) => $options.setRange("custom")),
c: $data.range === "week" ? 1 : "",
d: common_vendor.o(($event) => $options.setRange("week")),
e: $data.range === "today" ? 1 : "",
f: common_vendor.o(($event) => $options.setRange("today")),
g: $data.range === "month" ? 1 : "",
h: common_vendor.o(($event) => $options.setRange("month")),
i: $data.range === "year" ? 1 : "",
j: common_vendor.o(($event) => $options.setRange("year")),
k: $data.biz === "sale" ? 1 : "",
l: common_vendor.o(($event) => $data.biz = "sale"),
m: $data.biz === "purchase" ? 1 : "",
n: common_vendor.o(($event) => $data.biz = "purchase"),
o: $data.biz === "collection" ? 1 : "",
p: common_vendor.o(($event) => $data.biz = "collection"),
q: $data.biz === "capital" ? 1 : "",
r: common_vendor.o(($event) => $data.biz = "capital"),
s: $data.biz === "inventory" ? 1 : "",
t: common_vendor.o(($event) => $data.biz = "inventory"),
v: $data.sub === "out" ? 1 : "",
w: common_vendor.o(($event) => $data.sub = "out"),
x: $data.sub === "return" ? 1 : "",
y: common_vendor.o(($event) => $data.sub = "return"),
z: $data.sub === "receive" ? 1 : "",
A: common_vendor.o(($event) => $data.sub = "receive"),
B: common_vendor.o((...args) => $options.goCreate && $options.goCreate(...args)),
C: common_vendor.o((...args) => $options.reload && $options.reload(...args)),
D: $data.kw,
E: common_vendor.o(($event) => $data.kw = $event.detail.value),
F: $data.range === "custom"
}, $data.range === "custom" ? {
G: common_vendor.t($data.begin),
H: $data.begin,
I: common_vendor.o((e) => {
$data.begin = e.detail.value;
$options.reload();
}),
J: common_vendor.t($data.end),
K: $data.end,
L: common_vendor.o((e) => {
$data.end = e.detail.value;
$options.reload();
})
} : {}, {
M: common_vendor.t($options.totalAmount.toFixed(2)),
N: common_vendor.f($data.list, (o, k0, i0) => {
return {
a: common_vendor.t(o.orderDate),
b: common_vendor.t(o.customerName),
c: common_vendor.t(o.orderNo),
d: common_vendor.t(Number(o.amount).toFixed(2)),
e: o.id,
f: common_vendor.o(($event) => $options.open(o), o.id)
};
})
});
}
const MiniProgramPage = /* @__PURE__ */ common_vendor._export_sfc(_sfc_main, [["render", _sfc_render]]);
wx.createPage(MiniProgramPage);
//# sourceMappingURL=../../../.sourcemap/mp-weixin/pages/detail/index.js.map

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "明细",
"usingComponents": {}
}

View File

@@ -0,0 +1 @@
<view class="detail"><view class="filter-tabs"><view class="{{['tab', a && 'active']}}" bindtap="{{b}}">自定义</view><view class="{{['tab', c && 'active']}}" bindtap="{{d}}">本周</view><view class="{{['tab', e && 'active']}}" bindtap="{{f}}">今日</view><view class="{{['tab', g && 'active']}}" bindtap="{{h}}">本月</view><view class="{{['tab', i && 'active']}}" bindtap="{{j}}">本年</view></view><view class="biz-tabs"><view class="{{['biz', k && 'active']}}" bindtap="{{l}}">销售</view><view class="{{['biz', m && 'active']}}" bindtap="{{n}}">进货</view><view class="{{['biz', o && 'active']}}" bindtap="{{p}}">收款</view><view class="{{['biz', q && 'active']}}" bindtap="{{r}}">资金</view><view class="{{['biz', s && 'active']}}" bindtap="{{t}}">盘点</view></view><view class="card"><view class="subtabs"><view class="{{['sub', v && 'active']}}" bindtap="{{w}}">出货</view><view class="{{['sub', x && 'active']}}" bindtap="{{y}}">退货</view><view class="{{['sub', z && 'active']}}" bindtap="{{A}}">收款</view><view class="plus" bindtap="{{B}}">+</view></view><view class="search"><input placeholder="单据号/客户/名称/规格/备注" bindconfirm="{{C}}" value="{{D}}" bindinput="{{E}}"/></view><view wx:if="{{F}}" class="daterange"><picker mode="date" value="{{H}}" bindchange="{{I}}"><text>{{G}}</text></picker><text class="sep">—</text><picker mode="date" value="{{K}}" bindchange="{{L}}"><text>{{J}}</text></picker></view><view class="total">合计:¥ {{M}}</view><scroll-view scroll-y class="list"><view wx:for="{{N}}" wx:for-item="o" wx:key="e" class="row" bindtap="{{o.f}}"><view class="left"><view class="date">{{o.a}}</view><view class="name">{{o.b}}</view><view class="no">{{o.c}}</view></view><view class="right">¥ {{o.d}}</view></view></scroll-view></view></view>

View File

@@ -0,0 +1,43 @@
.detail { display:flex;
}
.filter-tabs { display:flex; gap: 24rpx; padding: 18rpx 24rpx; color:#666;
}
.filter-tabs .tab.active { color:#2aa7b6; font-weight: 700;
}
.biz-tabs { position: fixed; left:0; top: 160rpx; bottom: 120rpx; width: 120rpx; display:flex; flex-direction: column; gap: 24rpx; padding: 12rpx;
}
.biz { background:#6aa9ff; color:#fff; border-radius: 16rpx; padding: 20rpx 0; text-align:center; opacity: .85;
}
.biz.active { opacity: 1;
}
.card { margin-left: 140rpx; background:#fff; border-radius: 24rpx; padding: 16rpx;
}
.subtabs { display:flex; align-items:center; gap: 24rpx; padding: 8rpx 6rpx 12rpx;
}
.sub { color:#57c2cf; padding: 8rpx 12rpx;
}
.sub.active { border-bottom: 4rpx solid #57c2cf; font-weight:700;
}
.plus { margin-left:auto; width: 60rpx; height: 60rpx; border-radius: 30rpx; background:#2ec0d0; color:#fff; font-size: 40rpx; display:flex; align-items:center; justify-content:center;
}
.search { background:#f6f7fb; border-radius: 999rpx; padding: 14rpx 20rpx; margin: 8rpx 0 12rpx;
}
.daterange { display:flex; align-items:center; gap: 12rpx; color:#888; padding-bottom: 8rpx;
}
.daterange .sep { color:#ccc;
}
.total { color:#2ec0d0; font-weight: 800; padding: 12rpx 0; border-top: 2rpx solid #eaeaea;
}
.list { height: calc(100vh - 420rpx);
}
.row { display:flex; justify-content: space-between; align-items:center; padding: 22rpx 10rpx; border-bottom: 1rpx solid #f0f0f0;
}
.left .date { color:#999; margin-bottom: 6rpx;
}
.left .name { color:#333; margin-bottom: 6rpx;
}
.left .no { color:#bbb;
}
.right { color:#555;
}

View File

@@ -1,20 +1,17 @@
"use strict";
const common_vendor = require("../../common/vendor.js");
const common_http = require("../../common/http.js");
const common_assets = require("../../common/assets.js");
const _sfc_main = {
data() {
return {
todayAmount: "0.00",
monthProfit: "0.00",
stockQty: "0.00",
kpi: { todaySales: "0.00", monthSales: "0.00", monthProfit: "0.00", stockCount: "0" },
activeTab: "home",
notices: [
{ text: "选材精工:不锈钢、合金钢,耐腐蚀更耐用", tag: "品质" },
{ text: "表面工艺:电镀锌/镀镍/发黑处理,性能均衡", tag: "工艺" },
{ text: "库存齐全:螺丝、螺母、垫圈、膨胀螺栓等现货", tag: "库存" },
{ text: "企业采购支持:批量优惠,次日发货", tag: "服务" }
],
notices: [],
loadingNotices: false,
noticeError: "",
features: [
{ key: "product", title: "货品", img: "/static/icons/product.png", emoji: "📦" },
{ key: "customer", title: "客户", img: "/static/icons/customer.png", emoji: "👥" },
{ key: "sale", title: "销售", img: "/static/icons/sale.png", emoji: "💰" },
{ key: "account", title: "账户", img: "/static/icons/account.png", emoji: "💳" },
@@ -27,35 +24,78 @@ const _sfc_main = {
]
};
},
onLoad() {
this.fetchMetrics();
this.fetchNotices();
},
methods: {
async fetchMetrics() {
try {
const d = await common_http.get("/api/dashboard/overview");
const toNum = (v) => typeof v === "number" ? v : Number(v || 0);
this.kpi = {
...this.kpi,
todaySales: toNum(d && d.todaySalesAmount).toFixed(2),
monthSales: toNum(d && d.monthSalesAmount).toFixed(2),
monthProfit: toNum(d && d.monthGrossProfit).toFixed(2),
stockCount: String((d && d.stockTotalQuantity) != null ? d.stockTotalQuantity : 0)
};
} catch (e) {
}
},
async fetchNotices() {
this.loadingNotices = true;
this.noticeError = "";
try {
const list = await common_http.get("/api/notices");
this.notices = Array.isArray(list) ? list.map((n) => ({
text: n.content || n.title || "",
tag: n.tag || ""
})) : [];
} catch (e) {
this.noticeError = e && e.message || "公告加载失败";
} finally {
this.loadingNotices = false;
}
},
onFeatureTap(item) {
if (item.key === "product") {
common_vendor.index.navigateTo({ url: "/pages/product/list" });
return;
}
common_vendor.index.showToast({ title: item.title + "(开发中)", icon: "none" });
},
goProduct() {
this.activeTab = "product";
common_vendor.index.navigateTo({ url: "/pages/product/list" });
},
onCreateOrder() {
common_vendor.index.showToast({ title: "开单(开发中)", icon: "none" });
common_vendor.index.navigateTo({ url: "/pages/order/create" });
},
onNoticeTap(n) {
common_vendor.index.showModal({
title: "告",
content: n.text,
title: "广告",
content: n && (n.text || n.title || n.content) || "",
showCancel: false
});
},
onNoticeList() {
common_vendor.index.showToast({ title: "公告列表(开发中)", icon: "none" });
},
onIconError(item) {
item.img = "";
}
}
};
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return {
return common_vendor.e({
a: common_assets._imports_0,
b: common_vendor.t($data.todayAmount),
c: common_vendor.t($data.monthProfit),
d: common_vendor.t($data.stockQty),
e: common_vendor.f($data.notices, (n, idx, i0) => {
b: common_vendor.t($data.kpi.todaySales),
c: common_vendor.t($data.kpi.monthSales),
d: common_vendor.t($data.kpi.monthProfit),
e: common_vendor.t($data.kpi.stockCount),
f: $data.loadingNotices
}, $data.loadingNotices ? {} : $data.noticeError ? {
h: common_vendor.t($data.noticeError)
} : !$data.notices.length ? {} : {
j: common_vendor.f($data.notices, (n, idx, i0) => {
return common_vendor.e({
a: common_vendor.t(n.text),
b: n.tag
@@ -65,9 +105,11 @@ function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
d: common_vendor.o(($event) => $options.onNoticeTap(n), idx),
e: idx
});
}),
f: common_vendor.o((...args) => $options.onNoticeList && $options.onNoticeList(...args)),
g: common_vendor.f($data.features, (item, k0, i0) => {
})
}, {
g: $data.noticeError,
i: !$data.notices.length,
k: common_vendor.f($data.features, (item, k0, i0) => {
return common_vendor.e({
a: item.img
}, item.img ? {
@@ -82,14 +124,18 @@ function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
h: common_vendor.o(($event) => $options.onFeatureTap(item), item.key)
});
}),
h: $data.activeTab === "home" ? 1 : "",
i: common_vendor.o(($event) => $data.activeTab = "home"),
j: common_vendor.o((...args) => $options.onCreateOrder && $options.onCreateOrder(...args)),
k: $data.activeTab === "detail" ? 1 : "",
l: common_vendor.o(($event) => $data.activeTab = "detail"),
m: $data.activeTab === "me" ? 1 : "",
n: common_vendor.o(($event) => $data.activeTab = "me")
};
l: $data.activeTab === "home" ? 1 : "",
m: common_vendor.o(($event) => $data.activeTab = "home"),
n: $data.activeTab === "product" ? 1 : "",
o: common_vendor.o((...args) => $options.goProduct && $options.goProduct(...args)),
p: common_vendor.o((...args) => $options.onCreateOrder && $options.onCreateOrder(...args)),
q: $data.activeTab === "detail" ? 1 : "",
r: common_vendor.o(($event) => $data.activeTab = "detail"),
s: $data.activeTab === "report" ? 1 : "",
t: common_vendor.o(($event) => $data.activeTab = "report"),
v: $data.activeTab === "me" ? 1 : "",
w: common_vendor.o(($event) => $data.activeTab = "me")
});
}
const MiniProgramPage = /* @__PURE__ */ common_vendor._export_sfc(_sfc_main, [["render", _sfc_render]]);
wx.createPage(MiniProgramPage);

View File

@@ -1 +1 @@
<view class="home"><image class="home-bg" src="{{a}}" mode="aspectFill"></image><view class="hero"><view class="hero-top"><text class="brand">五金配件管家</text><view class="cta"><text class="cta-text">咨询</text></view></view><view class="kpi"><view class="kpi-item"><text class="kpi-label">今日销售额</text><text class="kpi-value">{{b}}</text></view><view class="kpi-item"><text class="kpi-label">本月利润</text><text class="kpi-value">{{c}}</text></view><view class="kpi-item"><text class="kpi-label">库存数量</text><text class="kpi-value">{{d}}</text></view></view></view><view class="notice"><view class="notice-left">公告</view><swiper class="notice-swiper" circular autoplay interval="4000" duration="400" vertical><swiper-item wx:for="{{e}}" wx:for-item="n" wx:key="e"><view class="notice-item" bindtap="{{n.d}}"><text class="notice-text">{{n.a}}</text><text wx:if="{{n.b}}" class="notice-tag">{{n.c}}</text></view></swiper-item></swiper><view class="notice-right" bindtap="{{f}}">更多</view></view><view class="section-title"><text class="section-text">常用功能</text></view><view class="grid-wrap"><view class="grid"><view wx:for="{{g}}" wx:for-item="item" wx:key="g" class="grid-item" bindtap="{{item.h}}"><view class="icon icon-squircle"><image wx:if="{{item.a}}" src="{{item.b}}" class="icon-img" mode="aspectFit" binderror="{{item.c}}"></image><text wx:elif="{{item.d}}" class="icon-emoji">{{item.e}}</text><view wx:else class="icon-placeholder"></view></view><text class="grid-chip">{{item.f}}</text></view></view></view><view class="bottom-bar"><view class="{{['tab', h && 'active']}}" bindtap="{{i}}"><text>首页</text></view><view class="tab primary" bindtap="{{j}}"><text>开单</text></view><view class="{{['tab', k && 'active']}}" bindtap="{{l}}"><text>明细</text></view><view class="{{['tab', m && 'active']}}" bindtap="{{n}}"><text>我的</text></view></view></view>
<view class="home"><image class="home-bg" src="{{a}}" mode="aspectFill"></image><view class="hero"><view class="hero-top"><text class="brand">五金配件管家</text><view class="cta"><text class="cta-text">咨询</text></view></view><view class="kpi"><view class="kpi-item"><text class="kpi-label">今日销售额</text><text class="kpi-value">{{b}}</text></view><view class="kpi-item"><text class="kpi-label">本月销售额</text><text class="kpi-value">{{c}}</text></view><view class="kpi-item"><text class="kpi-label">本月利润</text><text class="kpi-value">{{d}}</text></view><view class="kpi-item"><text class="kpi-label">库存商品数量</text><text class="kpi-value">{{e}}</text></view></view></view><view class="notice"><view class="notice-left">广告</view><view wx:if="{{f}}" class="notice-swiper" style="display:flex;align-items:center;color:#6b5a2a">加载中...</view><view wx:elif="{{g}}" class="notice-swiper" style="display:flex;align-items:center;color:#dd524d">{{h}}</view><view wx:elif="{{i}}" class="notice-swiper" style="display:flex;align-items:center;color:#6b5a2a">暂无公告</view><swiper wx:else class="notice-swiper" circular autoplay interval="4000" duration="400" vertical><swiper-item wx:for="{{j}}" wx:for-item="n" wx:key="e"><view class="notice-item" bindtap="{{n.d}}"><text class="notice-text">{{n.a}}</text><text wx:if="{{n.b}}" class="notice-tag">{{n.c}}</text></view></swiper-item></swiper></view><view class="section-title"><text class="section-text">常用功能</text></view><view class="grid-wrap"><view class="grid"><view wx:for="{{k}}" wx:for-item="item" wx:key="g" class="grid-item" bindtap="{{item.h}}"><view class="icon icon-squircle"><image wx:if="{{item.a}}" src="{{item.b}}" class="icon-img" mode="aspectFit" binderror="{{item.c}}"></image><text wx:elif="{{item.d}}" class="icon-emoji">{{item.e}}</text><view wx:else class="icon-placeholder"></view></view><text class="grid-chip">{{item.f}}</text></view></view></view><view class="bottom-bar"><view class="{{['tab', l && 'active']}}" bindtap="{{m}}"><text>首页</text></view><view class="{{['tab', n && 'active']}}" bindtap="{{o}}"><text>货品</text></view><view class="tab primary" bindtap="{{p}}"><text>开单</text></view><view class="{{['tab', q && 'active']}}" bindtap="{{r}}"><text>明细</text></view><view class="{{['tab', s && 'active']}}" bindtap="{{t}}"><text>报表</text></view><view class="{{['tab', v && 'active']}}" bindtap="{{w}}"><text>我的</text></view></view></view>

View File

@@ -48,8 +48,7 @@
}
.notice-tag { color: #B4880F; font-size: 22rpx; padding: 4rpx 10rpx; border-radius: 999rpx; background: rgba(215,167,46,0.18);
}
.notice-right { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; min-width: 72rpx; height: 44rpx; color: #B4880F; font-size: 26rpx; padding-left: 8rpx;
}
/* 分割标题 */
.section-title { display: flex; align-items: center; gap: 16rpx; padding: 10rpx 28rpx 0;

View File

@@ -0,0 +1,212 @@
"use strict";
const common_vendor = require("../../common/vendor.js");
const common_http = require("../../common/http.js");
const common_constants = require("../../common/constants.js");
const common_assets = require("../../common/assets.js");
function todayString() {
const d = /* @__PURE__ */ new Date();
const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
const _sfc_main = {
data() {
return {
biz: "sale",
saleType: "out",
purchaseType: "in",
order: {
orderTime: todayString(),
customerId: null,
supplierId: null,
remark: ""
},
customerName: "",
supplierName: "",
items: [],
activeCategory: "sale_income",
trxAmount: 0,
selectedAccountId: null,
selectedAccountName: ""
};
},
computed: {
totalQuantity() {
return this.items.reduce((s, it) => s + Number(it.quantity || 0), 0);
},
totalAmount() {
return this.items.reduce((s, it) => s + Number(it.quantity || 0) * Number(it.unitPrice || 0), 0);
},
customerLabel() {
return this.customerName || "零售客户";
},
supplierLabel() {
return this.supplierName || "零散供应商";
},
incomeCategories() {
return common_constants.INCOME_CATEGORIES;
},
expenseCategories() {
return common_constants.EXPENSE_CATEGORIES;
},
accountLabel() {
return this.selectedAccountName || "现金";
},
counterpartyLabel() {
return this.customerName || this.supplierName || "—";
}
},
methods: {
switchBiz(type) {
this.biz = type;
},
onDateChange(e) {
this.order.orderTime = e.detail.value;
},
chooseCustomer() {
common_vendor.index.navigateTo({ url: "/pages/customer/select" });
},
chooseSupplier() {
common_vendor.index.navigateTo({ url: "/pages/supplier/select" });
},
chooseProduct() {
common_vendor.index.navigateTo({ url: "/pages/product/select" });
},
chooseAccount() {
common_vendor.index.navigateTo({ url: "/pages/account/select" });
},
chooseCounterparty() {
if (this.biz === "income" || this.biz === "expense") {
common_vendor.index.navigateTo({ url: "/pages/customer/select" });
}
},
recalc() {
this.$forceUpdate();
},
async submit() {
const isSaleOrPurchase = this.biz === "sale" || this.biz === "purchase";
const payload = isSaleOrPurchase ? {
type: this.biz === "sale" ? this.saleType : "purchase." + this.purchaseType,
orderTime: this.order.orderTime,
customerId: this.order.customerId,
supplierId: this.order.supplierId,
items: this.items.map((it) => ({ productId: it.productId, quantity: Number(it.quantity || 0), unitPrice: Number(it.unitPrice || 0) })),
amount: this.totalAmount
} : {
type: this.biz,
category: this.activeCategory,
counterpartyId: this.order.customerId || null,
accountId: this.selectedAccountId || null,
amount: Number(this.trxAmount || 0),
txTime: this.order.orderTime,
remark: this.order.remark
};
try {
const url = isSaleOrPurchase ? "/api/orders" : "/api/other-transactions";
await common_http.post(url, payload);
common_vendor.index.showToast({ title: "已保存", icon: "success" });
setTimeout(() => {
common_vendor.index.navigateBack();
}, 600);
} catch (e) {
common_vendor.index.showToast({ title: e && e.message || "保存失败", icon: "none" });
}
},
saveAndReset() {
this.items = [];
this.trxAmount = 0;
this.order.remark = "";
}
}
};
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return common_vendor.e({
a: $data.biz === "sale" ? 1 : "",
b: common_vendor.o(($event) => $options.switchBiz("sale")),
c: $data.biz === "purchase" ? 1 : "",
d: common_vendor.o(($event) => $options.switchBiz("purchase")),
e: $data.biz === "income" ? 1 : "",
f: common_vendor.o(($event) => $options.switchBiz("income")),
g: $data.biz === "expense" ? 1 : "",
h: common_vendor.o(($event) => $options.switchBiz("expense")),
i: $data.biz === "sale"
}, $data.biz === "sale" ? {
j: $data.saleType === "out" ? 1 : "",
k: common_vendor.o(($event) => $data.saleType = "out"),
l: $data.saleType === "return" ? 1 : "",
m: common_vendor.o(($event) => $data.saleType = "return"),
n: $data.saleType === "collect" ? 1 : "",
o: common_vendor.o(($event) => $data.saleType = "collect")
} : $data.biz === "purchase" ? {
q: $data.purchaseType === "in" ? 1 : "",
r: common_vendor.o(($event) => $data.purchaseType = "in"),
s: $data.purchaseType === "return" ? 1 : "",
t: common_vendor.o(($event) => $data.purchaseType = "return"),
v: $data.purchaseType === "pay" ? 1 : "",
w: common_vendor.o(($event) => $data.purchaseType = "pay")
} : {}, {
p: $data.biz === "purchase",
x: common_vendor.t($data.order.orderTime),
y: $data.order.orderTime,
z: common_vendor.o((...args) => $options.onDateChange && $options.onDateChange(...args)),
A: $data.biz === "sale"
}, $data.biz === "sale" ? {
B: common_vendor.t($options.customerLabel),
C: common_vendor.o((...args) => $options.chooseCustomer && $options.chooseCustomer(...args))
} : $data.biz === "purchase" ? {
E: common_vendor.t($options.supplierLabel),
F: common_vendor.o((...args) => $options.chooseSupplier && $options.chooseSupplier(...args))
} : {}, {
D: $data.biz === "purchase",
G: $data.biz === "sale" || $data.biz === "purchase"
}, $data.biz === "sale" || $data.biz === "purchase" ? {
H: common_vendor.t($options.totalQuantity),
I: common_vendor.t($options.totalAmount.toFixed(2)),
J: common_vendor.o((...args) => $options.chooseProduct && $options.chooseProduct(...args))
} : {
K: common_vendor.f($data.biz === "income" ? $options.incomeCategories : $options.expenseCategories, (c, k0, i0) => {
return {
a: common_vendor.t(c.label),
b: c.key,
c: $data.activeCategory === c.key ? 1 : "",
d: common_vendor.o(($event) => $data.activeCategory = c.key, c.key)
};
}),
L: common_vendor.t($options.counterpartyLabel),
M: common_vendor.o((...args) => $options.chooseCounterparty && $options.chooseCounterparty(...args)),
N: common_vendor.t($options.accountLabel),
O: common_vendor.o((...args) => $options.chooseAccount && $options.chooseAccount(...args)),
P: $data.trxAmount,
Q: common_vendor.o(common_vendor.m(($event) => $data.trxAmount = $event.detail.value, {
number: true
})),
R: $data.order.remark,
S: common_vendor.o(($event) => $data.order.remark = $event.detail.value)
}, {
T: !$data.items.length
}, !$data.items.length ? {
U: common_assets._imports_0$1
} : {
V: common_vendor.f($data.items, (it, idx, i0) => {
return {
a: common_vendor.t(it.productName),
b: common_vendor.o([common_vendor.m(($event) => it.quantity = $event.detail.value, {
number: true
}), ($event) => $options.recalc()], idx),
c: it.quantity,
d: common_vendor.o([common_vendor.m(($event) => it.unitPrice = $event.detail.value, {
number: true
}), ($event) => $options.recalc()], idx),
e: it.unitPrice,
f: common_vendor.t((Number(it.quantity) * Number(it.unitPrice)).toFixed(2)),
g: idx
};
})
}, {
W: common_vendor.o((...args) => $options.saveAndReset && $options.saveAndReset(...args)),
X: common_vendor.o((...args) => $options.submit && $options.submit(...args))
});
}
const MiniProgramPage = /* @__PURE__ */ common_vendor._export_sfc(_sfc_main, [["render", _sfc_render]]);
wx.createPage(MiniProgramPage);
//# sourceMappingURL=../../../.sourcemap/mp-weixin/pages/order/create.js.map

Some files were not shown because too many files have changed in this diff Show More