diff --git a/config/migration/member/legacy-import.sql b/config/migration/member/legacy-import.sql
new file mode 100644
index 00000000..e1d3a027
--- /dev/null
+++ b/config/migration/member/legacy-import.sql
@@ -0,0 +1,277 @@
+-- 会员域旧库导入脚本
+-- 用途:
+-- 1. 从旧库 staging 数据库导入会员与 OAuth2 绑定数据
+-- 2. 只负责搬运旧值,不做状态翻转
+-- 3. 状态语义修正请继续执行 legacy-status-remap.sql
+--
+-- 运行前提:
+-- 1. 当前连接数据库应为新架构目标库
+-- 2. 通过 psql 变量 source_conn 传入 dblink 连接串
+-- 3. 目标库已完成 Flyway 初始化
+
+BEGIN;
+
+CREATE EXTENSION IF NOT EXISTS dblink;
+
+WITH legacy_member AS (
+ SELECT *
+ FROM dblink(:'source_conn', $db$
+ SELECT id,
+ username,
+ email,
+ phone,
+ password_hash,
+ name,
+ nickname,
+ avatar_url,
+ gender,
+ birth_date,
+ status,
+ member_level,
+ points,
+ balance,
+ register_source,
+ referrer_id,
+ last_login_time,
+ email_verified_time,
+ phone_verified_time,
+ profile,
+ preferences,
+ created_time,
+ updated_time,
+ del_flag
+ FROM mortise.mortise_member
+ $db$) AS t(
+ id BIGINT,
+ username VARCHAR(50),
+ email VARCHAR(100),
+ phone VARCHAR(20),
+ password_hash VARCHAR(255),
+ name VARCHAR(100),
+ nickname VARCHAR(50),
+ avatar_url VARCHAR(500),
+ gender VARCHAR(10),
+ birth_date DATE,
+ status INTEGER,
+ member_level VARCHAR(20),
+ points INTEGER,
+ balance NUMERIC(10, 2),
+ register_source VARCHAR(50),
+ referrer_id BIGINT,
+ last_login_time TIMESTAMP,
+ email_verified_time TIMESTAMP,
+ phone_verified_time TIMESTAMP,
+ profile JSONB,
+ preferences JSONB,
+ created_time TIMESTAMP,
+ updated_time TIMESTAMP,
+ del_flag INTEGER
+ )
+)
+INSERT INTO mortise.mortise_member (
+ id,
+ username,
+ email,
+ phone,
+ password_hash,
+ name,
+ nickname,
+ avatar_url,
+ gender,
+ birth_date,
+ status,
+ member_level,
+ points,
+ balance,
+ register_source,
+ referrer_id,
+ last_login_time,
+ email_verified_time,
+ phone_verified_time,
+ profile,
+ preferences,
+ created_time,
+ updated_time,
+ del_flag,
+ current_family_id
+)
+SELECT id,
+ username,
+ email,
+ phone,
+ password_hash,
+ name,
+ nickname,
+ avatar_url,
+ gender,
+ birth_date,
+ COALESCE(status, 0),
+ COALESCE(member_level, 'normal'),
+ COALESCE(points, 0),
+ COALESCE(balance, 0.00),
+ register_source,
+ referrer_id,
+ last_login_time,
+ email_verified_time,
+ phone_verified_time,
+ profile,
+ preferences,
+ COALESCE(created_time, CURRENT_TIMESTAMP),
+ COALESCE(updated_time, COALESCE(created_time, CURRENT_TIMESTAMP)),
+ COALESCE(del_flag, 0),
+ NULL::BIGINT
+FROM legacy_member
+ON CONFLICT (id) DO UPDATE
+SET username = EXCLUDED.username,
+ email = EXCLUDED.email,
+ phone = EXCLUDED.phone,
+ password_hash = EXCLUDED.password_hash,
+ name = EXCLUDED.name,
+ nickname = EXCLUDED.nickname,
+ avatar_url = EXCLUDED.avatar_url,
+ gender = EXCLUDED.gender,
+ birth_date = EXCLUDED.birth_date,
+ status = EXCLUDED.status,
+ member_level = EXCLUDED.member_level,
+ points = EXCLUDED.points,
+ balance = EXCLUDED.balance,
+ register_source = EXCLUDED.register_source,
+ referrer_id = EXCLUDED.referrer_id,
+ last_login_time = EXCLUDED.last_login_time,
+ email_verified_time = EXCLUDED.email_verified_time,
+ phone_verified_time = EXCLUDED.phone_verified_time,
+ profile = EXCLUDED.profile,
+ preferences = EXCLUDED.preferences,
+ created_time = EXCLUDED.created_time,
+ updated_time = EXCLUDED.updated_time,
+ del_flag = EXCLUDED.del_flag;
+
+WITH legacy_binding AS (
+ SELECT *
+ FROM dblink(:'source_conn', $db$
+ SELECT id,
+ member_id,
+ provider,
+ open_id,
+ union_id,
+ nickname,
+ avatar,
+ email,
+ phone,
+ gender,
+ country,
+ province,
+ city,
+ access_token,
+ refresh_token,
+ expires_at,
+ raw_data,
+ status,
+ del_flag,
+ created_time,
+ updated_time
+ FROM mortise.mortise_member_oauth2_binding
+ $db$) AS t(
+ id BIGINT,
+ member_id BIGINT,
+ provider VARCHAR(50),
+ open_id VARCHAR(255),
+ union_id VARCHAR(255),
+ nickname VARCHAR(100),
+ avatar VARCHAR(500),
+ email VARCHAR(100),
+ phone VARCHAR(20),
+ gender VARCHAR(10),
+ country VARCHAR(50),
+ province VARCHAR(50),
+ city VARCHAR(50),
+ access_token TEXT,
+ refresh_token TEXT,
+ expires_at TIMESTAMP,
+ raw_data TEXT,
+ status INTEGER,
+ del_flag INTEGER,
+ created_time TIMESTAMP,
+ updated_time TIMESTAMP
+ )
+)
+INSERT INTO mortise.mortise_member_oauth2_binding (
+ id,
+ member_id,
+ provider,
+ open_id,
+ union_id,
+ nickname,
+ avatar,
+ email,
+ phone,
+ gender,
+ country,
+ province,
+ city,
+ access_token,
+ refresh_token,
+ expires_at,
+ raw_data,
+ status,
+ del_flag,
+ created_time,
+ updated_time
+)
+SELECT b.id,
+ b.member_id,
+ b.provider,
+ b.open_id,
+ b.union_id,
+ b.nickname,
+ b.avatar,
+ b.email,
+ b.phone,
+ b.gender,
+ b.country,
+ b.province,
+ b.city,
+ b.access_token,
+ b.refresh_token,
+ b.expires_at,
+ b.raw_data,
+ COALESCE(b.status, 0),
+ COALESCE(b.del_flag, 0),
+ COALESCE(b.created_time, CURRENT_TIMESTAMP),
+ COALESCE(b.updated_time, COALESCE(b.created_time, CURRENT_TIMESTAMP))
+FROM legacy_binding b
+WHERE EXISTS (
+ SELECT 1
+ FROM mortise.mortise_member m
+ WHERE m.id = b.member_id
+)
+ON CONFLICT (id) DO UPDATE
+SET member_id = EXCLUDED.member_id,
+ provider = EXCLUDED.provider,
+ open_id = EXCLUDED.open_id,
+ union_id = EXCLUDED.union_id,
+ nickname = EXCLUDED.nickname,
+ avatar = EXCLUDED.avatar,
+ email = EXCLUDED.email,
+ phone = EXCLUDED.phone,
+ gender = EXCLUDED.gender,
+ country = EXCLUDED.country,
+ province = EXCLUDED.province,
+ city = EXCLUDED.city,
+ access_token = EXCLUDED.access_token,
+ refresh_token = EXCLUDED.refresh_token,
+ expires_at = EXCLUDED.expires_at,
+ raw_data = EXCLUDED.raw_data,
+ status = EXCLUDED.status,
+ del_flag = EXCLUDED.del_flag,
+ created_time = EXCLUDED.created_time,
+ updated_time = EXCLUDED.updated_time;
+
+COMMIT;
+
+SELECT 'mortise_member' AS table_name, count(*) AS row_count
+FROM mortise.mortise_member
+UNION ALL
+SELECT 'mortise_member_oauth2_binding' AS table_name, count(*) AS row_count
+FROM mortise.mortise_member_oauth2_binding
+ORDER BY table_name;
diff --git a/config/migration/member/legacy-post-import-validation.sql b/config/migration/member/legacy-post-import-validation.sql
new file mode 100644
index 00000000..8a1fb94e
--- /dev/null
+++ b/config/migration/member/legacy-post-import-validation.sql
@@ -0,0 +1,75 @@
+-- 会员域旧库导入后校验 SQL
+-- 用途:
+-- 1. 对比会员与 OAuth2 绑定导入后的行数
+-- 2. 检查 status 翻转后是否仍存在异常值或关联脏数据
+-- 3. 为认证链路回归提供基础验收数据
+
+SELECT CURRENT_TIMESTAMP AS validation_time;
+
+-- 1. 行数概览
+SELECT 'row_count' AS check_type,
+ 'mortise_member' AS target_table,
+ COUNT(*) AS total_rows,
+ COUNT(*) FILTER (WHERE del_flag = 0) AS active_rows
+FROM mortise.mortise_member
+UNION ALL
+SELECT 'row_count', 'mortise_member_oauth2_binding', COUNT(*), COUNT(*) FILTER (WHERE del_flag = 0)
+FROM mortise.mortise_member_oauth2_binding
+ORDER BY target_table;
+
+-- 2. status 分布
+SELECT 'status_distribution' AS check_type,
+ 'mortise_member' AS target_table,
+ status::TEXT AS status_value,
+ COUNT(*) AS row_count
+FROM mortise.mortise_member
+GROUP BY status
+UNION ALL
+SELECT 'status_distribution', 'mortise_member_oauth2_binding', status::TEXT, COUNT(*)
+FROM mortise.mortise_member_oauth2_binding
+GROUP BY status
+ORDER BY target_table, status_value;
+
+-- 3. 结构与语义校验
+SELECT 'member_invalid_status' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_member
+WHERE status NOT IN (0, 1)
+ OR status IS NULL;
+
+SELECT 'member_oauth2_binding_invalid_status' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_member_oauth2_binding
+WHERE status NOT IN (0, 1)
+ OR status IS NULL;
+
+SELECT 'oauth2_binding_orphan_member' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_member_oauth2_binding binding
+LEFT JOIN mortise.mortise_member member ON member.id = binding.member_id
+WHERE member.id IS NULL;
+
+SELECT 'enabled_binding_linked_disabled_member' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_member_oauth2_binding binding
+JOIN mortise.mortise_member member ON member.id = binding.member_id
+WHERE binding.status = 1
+ AND member.status = 0;
+
+SELECT 'duplicate_provider_open_id' AS check_name,
+ COUNT(*) AS issue_count
+FROM (
+ SELECT provider, open_id
+ FROM mortise.mortise_member_oauth2_binding
+ WHERE del_flag = 0
+ GROUP BY provider, open_id
+ HAVING COUNT(*) > 1
+ ) duplicates;
+
+SELECT 'member_missing_login_identifier' AS check_name,
+ COUNT(*) AS review_count
+FROM mortise.mortise_member
+WHERE del_flag = 0
+ AND NULLIF(BTRIM(COALESCE(username, '')), '') IS NULL
+ AND NULLIF(BTRIM(COALESCE(email, '')), '') IS NULL
+ AND NULLIF(BTRIM(COALESCE(phone, '')), '') IS NULL;
diff --git a/config/migration/member/legacy-status-remap.sql b/config/migration/member/legacy-status-remap.sql
new file mode 100644
index 00000000..2dc9d0c9
--- /dev/null
+++ b/config/migration/member/legacy-status-remap.sql
@@ -0,0 +1,33 @@
+-- 会员域旧库导入后状态修复脚本
+-- 用途:
+-- 1. 把 old-codes 旧语义 0=正常,1=禁用 的会员状态翻转为新语义 0=禁用,1=启用
+-- 2. 同步修正 OAuth2 绑定状态,避免导入后账号可用性失真
+--
+-- 注意:
+-- 1. 该脚本不是 Flyway,不接入应用启动流程
+-- 2. 该脚本按“旧值尚未翻转”假设编写,重复执行会再次翻转 0/1
+-- 3. 执行前请先备份测试环境数据库
+
+BEGIN;
+
+-- 会员状态:旧语义 0=正常,1=禁用 -> 新语义 0=禁用,1=启用
+UPDATE mortise.mortise_member
+SET status = CASE status
+ WHEN 0 THEN 1
+ WHEN 1 THEN 0
+ ELSE status
+ END,
+ updated_time = CURRENT_TIMESTAMP
+WHERE status IN (0, 1);
+
+-- OAuth2 绑定状态:旧语义 0=正常,1=禁用 -> 新语义 0=禁用,1=启用
+UPDATE mortise.mortise_member_oauth2_binding
+SET status = CASE status
+ WHEN 0 THEN 1
+ WHEN 1 THEN 0
+ ELSE status
+ END,
+ updated_time = CURRENT_TIMESTAMP
+WHERE status IN (0, 1);
+
+COMMIT;
diff --git a/config/migration/product/legacy-import.sql b/config/migration/product/legacy-import.sql
new file mode 100644
index 00000000..dec03f91
--- /dev/null
+++ b/config/migration/product/legacy-import.sql
@@ -0,0 +1,486 @@
+-- 商品域旧库导入脚本
+-- 用途:
+-- 1. 从旧库 staging 数据库导入分类、产品、SKU 与 SKU 目标映射
+-- 2. 只负责搬运旧值,不做状态翻转
+-- 3. 状态语义修正请继续执行 legacy-status-remap.sql
+
+BEGIN;
+
+CREATE EXTENSION IF NOT EXISTS dblink;
+
+WITH legacy_category AS (
+ SELECT *
+ FROM dblink(:'source_conn', $db$
+ SELECT id,
+ name,
+ slug,
+ description,
+ parent_id,
+ image_url,
+ sort_no,
+ is_active,
+ status,
+ metadata,
+ created_time,
+ updated_time,
+ del_flag
+ FROM mortise.mortise_product_category
+ $db$) AS t(
+ id BIGINT,
+ name VARCHAR(100),
+ slug VARCHAR(100),
+ description TEXT,
+ parent_id BIGINT,
+ image_url VARCHAR(500),
+ sort_no INTEGER,
+ is_active BOOLEAN,
+ status INTEGER,
+ metadata JSONB,
+ created_time TIMESTAMP,
+ updated_time TIMESTAMP,
+ del_flag INTEGER
+ )
+)
+INSERT INTO mortise.mortise_product_category (
+ id,
+ name,
+ slug,
+ description,
+ parent_id,
+ image_url,
+ sort_no,
+ is_active,
+ status,
+ metadata,
+ created_time,
+ updated_time,
+ del_flag
+)
+SELECT id,
+ name,
+ slug,
+ description,
+ parent_id,
+ image_url,
+ COALESCE(sort_no, 0),
+ COALESCE(is_active, FALSE),
+ COALESCE(status, 0),
+ metadata,
+ COALESCE(created_time, CURRENT_TIMESTAMP),
+ COALESCE(updated_time, COALESCE(created_time, CURRENT_TIMESTAMP)),
+ COALESCE(del_flag, 0)
+FROM legacy_category
+ON CONFLICT (id) DO UPDATE
+SET name = EXCLUDED.name,
+ slug = EXCLUDED.slug,
+ description = EXCLUDED.description,
+ parent_id = EXCLUDED.parent_id,
+ image_url = EXCLUDED.image_url,
+ sort_no = EXCLUDED.sort_no,
+ is_active = EXCLUDED.is_active,
+ status = EXCLUDED.status,
+ metadata = EXCLUDED.metadata,
+ created_time = EXCLUDED.created_time,
+ updated_time = EXCLUDED.updated_time,
+ del_flag = EXCLUDED.del_flag;
+
+WITH legacy_product AS (
+ SELECT *
+ FROM dblink(:'source_conn', $db$
+ SELECT id,
+ title,
+ subtitle,
+ description,
+ short_description,
+ cover_image_url,
+ gallery_images,
+ product_type,
+ category_id,
+ tags,
+ features,
+ specifications,
+ seo_title,
+ seo_description,
+ seo_keywords,
+ status,
+ is_featured,
+ is_digital,
+ requires_shipping,
+ weight_grams,
+ dimensions,
+ sort_no,
+ view_count,
+ sale_count,
+ rating_average,
+ rating_count,
+ created_by,
+ created_time,
+ updated_time,
+ published_time,
+ del_flag
+ FROM mortise.mortise_product
+ $db$) AS t(
+ id BIGINT,
+ title VARCHAR(200),
+ subtitle VARCHAR(500),
+ description TEXT,
+ short_description VARCHAR(1000),
+ cover_image_url VARCHAR(500),
+ gallery_images TEXT[],
+ product_type VARCHAR(20),
+ category_id BIGINT,
+ tags VARCHAR(100)[],
+ features JSONB,
+ specifications JSONB,
+ seo_title VARCHAR(200),
+ seo_description VARCHAR(500),
+ seo_keywords VARCHAR(500),
+ status INTEGER,
+ is_featured BOOLEAN,
+ is_digital BOOLEAN,
+ requires_shipping BOOLEAN,
+ weight_grams INTEGER,
+ dimensions JSONB,
+ sort_no INTEGER,
+ view_count INTEGER,
+ sale_count INTEGER,
+ rating_average NUMERIC(3, 2),
+ rating_count INTEGER,
+ created_by BIGINT,
+ created_time TIMESTAMP,
+ updated_time TIMESTAMP,
+ published_time TIMESTAMP,
+ del_flag INTEGER
+ )
+)
+INSERT INTO mortise.mortise_product (
+ id,
+ title,
+ subtitle,
+ description,
+ short_description,
+ cover_image_url,
+ gallery_images,
+ product_type,
+ category_id,
+ tags,
+ features,
+ specifications,
+ seo_title,
+ seo_description,
+ seo_keywords,
+ status,
+ is_featured,
+ sort_no,
+ created_by,
+ created_time,
+ updated_time,
+ published_time,
+ del_flag,
+ product_code
+)
+SELECT id,
+ title,
+ subtitle,
+ description,
+ short_description,
+ cover_image_url,
+ gallery_images,
+ product_type,
+ category_id,
+ tags,
+ features,
+ CASE
+ WHEN is_digital IS NULL
+ AND requires_shipping IS NULL
+ AND weight_grams IS NULL
+ AND dimensions IS NULL
+ AND view_count IS NULL
+ AND sale_count IS NULL
+ AND rating_average IS NULL
+ AND rating_count IS NULL
+ THEN specifications
+ ELSE COALESCE(specifications, '{}'::jsonb) ||
+ jsonb_build_object(
+ '_legacySalesFields',
+ jsonb_strip_nulls(
+ jsonb_build_object(
+ 'isDigital', is_digital,
+ 'requiresShipping', requires_shipping,
+ 'weightGrams', weight_grams,
+ 'dimensions', dimensions,
+ 'viewCount', view_count,
+ 'saleCount', sale_count,
+ 'ratingAverage', rating_average,
+ 'ratingCount', rating_count
+ )
+ )
+ )
+ END,
+ seo_title,
+ seo_description,
+ seo_keywords,
+ COALESCE(status, 0),
+ COALESCE(is_featured, FALSE),
+ COALESCE(sort_no, 0),
+ created_by,
+ COALESCE(created_time, CURRENT_TIMESTAMP),
+ COALESCE(updated_time, COALESCE(created_time, CURRENT_TIMESTAMP)),
+ published_time,
+ COALESCE(del_flag, 0),
+ CONCAT('LEG-', id::text)
+FROM legacy_product
+ON CONFLICT (id) DO UPDATE
+SET title = EXCLUDED.title,
+ subtitle = EXCLUDED.subtitle,
+ description = EXCLUDED.description,
+ short_description = EXCLUDED.short_description,
+ cover_image_url = EXCLUDED.cover_image_url,
+ gallery_images = EXCLUDED.gallery_images,
+ product_type = EXCLUDED.product_type,
+ category_id = EXCLUDED.category_id,
+ tags = EXCLUDED.tags,
+ features = EXCLUDED.features,
+ specifications = EXCLUDED.specifications,
+ seo_title = EXCLUDED.seo_title,
+ seo_description = EXCLUDED.seo_description,
+ seo_keywords = EXCLUDED.seo_keywords,
+ status = EXCLUDED.status,
+ is_featured = EXCLUDED.is_featured,
+ sort_no = EXCLUDED.sort_no,
+ created_by = EXCLUDED.created_by,
+ created_time = EXCLUDED.created_time,
+ updated_time = EXCLUDED.updated_time,
+ published_time = EXCLUDED.published_time,
+ del_flag = EXCLUDED.del_flag,
+ product_code = EXCLUDED.product_code;
+
+WITH legacy_sku AS (
+ SELECT *
+ FROM dblink(:'source_conn', $db$
+ SELECT id,
+ product_id,
+ sku_code,
+ name,
+ description,
+ attributes,
+ original_price,
+ current_price,
+ cost_price,
+ currency,
+ inventory_type,
+ stock_quantity,
+ reserved_quantity,
+ low_stock_threshold,
+ sell_start_time,
+ sell_end_time,
+ max_purchase_quantity,
+ min_purchase_quantity,
+ status,
+ is_default,
+ weight_grams,
+ dimensions,
+ shipping_template_id,
+ sale_count,
+ view_count,
+ metadata,
+ created_time,
+ updated_time,
+ del_flag
+ FROM mortise.mortise_product_sku
+ $db$) AS t(
+ id BIGINT,
+ product_id BIGINT,
+ sku_code VARCHAR(100),
+ name VARCHAR(200),
+ description TEXT,
+ attributes JSONB,
+ original_price NUMERIC(10, 2),
+ current_price NUMERIC(10, 2),
+ cost_price NUMERIC(10, 2),
+ currency VARCHAR(10),
+ inventory_type VARCHAR(20),
+ stock_quantity INTEGER,
+ reserved_quantity INTEGER,
+ low_stock_threshold INTEGER,
+ sell_start_time TIMESTAMP,
+ sell_end_time TIMESTAMP,
+ max_purchase_quantity INTEGER,
+ min_purchase_quantity INTEGER,
+ status VARCHAR(20),
+ is_default BOOLEAN,
+ weight_grams INTEGER,
+ dimensions JSONB,
+ shipping_template_id BIGINT,
+ sale_count INTEGER,
+ view_count INTEGER,
+ metadata JSONB,
+ created_time TIMESTAMP,
+ updated_time TIMESTAMP,
+ del_flag INTEGER
+ )
+)
+INSERT INTO mortise.mortise_product_sku (
+ id,
+ product_id,
+ sku_code,
+ name,
+ description,
+ attributes,
+ status,
+ is_default,
+ metadata,
+ created_time,
+ updated_time,
+ del_flag
+)
+SELECT s.id,
+ s.product_id,
+ s.sku_code,
+ s.name,
+ s.description,
+ s.attributes,
+ COALESCE(s.status, 'active'),
+ COALESCE(s.is_default, FALSE),
+ COALESCE(s.metadata, '{}'::jsonb) ||
+ jsonb_build_object(
+ '_legacySalesFields',
+ jsonb_strip_nulls(
+ jsonb_build_object(
+ 'originalPrice', s.original_price,
+ 'currentPrice', s.current_price,
+ 'costPrice', s.cost_price,
+ 'currency', s.currency,
+ 'inventoryType', s.inventory_type,
+ 'stockQuantity', s.stock_quantity,
+ 'reservedQuantity', s.reserved_quantity,
+ 'lowStockThreshold', s.low_stock_threshold,
+ 'sellStartTime', s.sell_start_time,
+ 'sellEndTime', s.sell_end_time,
+ 'maxPurchaseQuantity', s.max_purchase_quantity,
+ 'minPurchaseQuantity', s.min_purchase_quantity,
+ 'weightGrams', s.weight_grams,
+ 'dimensions', s.dimensions,
+ 'shippingTemplateId', s.shipping_template_id,
+ 'saleCount', s.sale_count,
+ 'viewCount', s.view_count
+ )
+ )
+ ),
+ COALESCE(s.created_time, CURRENT_TIMESTAMP),
+ COALESCE(s.updated_time, COALESCE(s.created_time, CURRENT_TIMESTAMP)),
+ COALESCE(s.del_flag, 0)
+FROM legacy_sku s
+WHERE EXISTS (
+ SELECT 1
+ FROM mortise.mortise_product p
+ WHERE p.id = s.product_id
+)
+ON CONFLICT (id) DO UPDATE
+SET product_id = EXCLUDED.product_id,
+ sku_code = EXCLUDED.sku_code,
+ name = EXCLUDED.name,
+ description = EXCLUDED.description,
+ attributes = EXCLUDED.attributes,
+ status = EXCLUDED.status,
+ is_default = EXCLUDED.is_default,
+ metadata = EXCLUDED.metadata,
+ created_time = EXCLUDED.created_time,
+ updated_time = EXCLUDED.updated_time,
+ del_flag = EXCLUDED.del_flag;
+
+WITH legacy_sku_target AS (
+ SELECT *
+ FROM dblink(:'source_conn', $db$
+ SELECT id,
+ product_sku_id,
+ target_type,
+ target_id,
+ quantity,
+ validity_days,
+ access_level,
+ conditions,
+ metadata,
+ status,
+ created_time,
+ updated_time,
+ del_flag
+ FROM mortise.mortise_product_sku_target
+ $db$) AS t(
+ id BIGINT,
+ product_sku_id BIGINT,
+ target_type VARCHAR(30),
+ target_id BIGINT,
+ quantity INTEGER,
+ validity_days INTEGER,
+ access_level VARCHAR(20),
+ conditions JSONB,
+ metadata JSONB,
+ status INTEGER,
+ created_time TIMESTAMP,
+ updated_time TIMESTAMP,
+ del_flag INTEGER
+ )
+)
+INSERT INTO mortise.mortise_product_sku_target (
+ id,
+ product_sku_id,
+ target_type,
+ target_id,
+ quantity,
+ validity_days,
+ access_level,
+ conditions,
+ metadata,
+ status,
+ created_time,
+ updated_time,
+ del_flag
+)
+SELECT st.id,
+ st.product_sku_id,
+ st.target_type,
+ st.target_id,
+ COALESCE(st.quantity, 1),
+ st.validity_days,
+ COALESCE(st.access_level, 'full'),
+ st.conditions,
+ st.metadata,
+ COALESCE(st.status, 0),
+ COALESCE(st.created_time, CURRENT_TIMESTAMP),
+ COALESCE(st.updated_time, COALESCE(st.created_time, CURRENT_TIMESTAMP)),
+ COALESCE(st.del_flag, 0)
+FROM legacy_sku_target st
+WHERE EXISTS (
+ SELECT 1
+ FROM mortise.mortise_product_sku sku
+ WHERE sku.id = st.product_sku_id
+)
+ON CONFLICT (id) DO UPDATE
+SET product_sku_id = EXCLUDED.product_sku_id,
+ target_type = EXCLUDED.target_type,
+ target_id = EXCLUDED.target_id,
+ quantity = EXCLUDED.quantity,
+ validity_days = EXCLUDED.validity_days,
+ access_level = EXCLUDED.access_level,
+ conditions = EXCLUDED.conditions,
+ metadata = EXCLUDED.metadata,
+ status = EXCLUDED.status,
+ created_time = EXCLUDED.created_time,
+ updated_time = EXCLUDED.updated_time,
+ del_flag = EXCLUDED.del_flag;
+
+COMMIT;
+
+SELECT 'mortise_product_category' AS table_name, count(*) AS row_count
+FROM mortise.mortise_product_category
+UNION ALL
+SELECT 'mortise_product' AS table_name, count(*) AS row_count
+FROM mortise.mortise_product
+UNION ALL
+SELECT 'mortise_product_sku' AS table_name, count(*) AS row_count
+FROM mortise.mortise_product_sku
+UNION ALL
+SELECT 'mortise_product_sku_target' AS table_name, count(*) AS row_count
+FROM mortise.mortise_product_sku_target
+ORDER BY table_name;
diff --git a/config/migration/product/legacy-post-import-validation.sql b/config/migration/product/legacy-post-import-validation.sql
new file mode 100644
index 00000000..ef2b7800
--- /dev/null
+++ b/config/migration/product/legacy-post-import-validation.sql
@@ -0,0 +1,102 @@
+-- 商品域旧库导入后校验 SQL
+-- 用途:
+-- 1. 对比商品分类、目录、SKU 与 SKU 目标映射的导入行数
+-- 2. 检查 ProductCategory / ProductSkuTarget 的状态翻转结果
+-- 3. 校验 SKU 默认项与目标映射关系是否完整
+
+SELECT CURRENT_TIMESTAMP AS validation_time;
+
+-- 1. 行数概览
+SELECT 'row_count' AS check_type,
+ 'mortise_product_category' AS target_table,
+ COUNT(*) AS total_rows,
+ COUNT(*) FILTER (WHERE del_flag = 0) AS active_rows
+FROM mortise.mortise_product_category
+UNION ALL
+SELECT 'row_count', 'mortise_product', COUNT(*), COUNT(*) FILTER (WHERE del_flag = 0)
+FROM mortise.mortise_product
+UNION ALL
+SELECT 'row_count', 'mortise_product_sku', COUNT(*), COUNT(*) FILTER (WHERE del_flag = 0)
+FROM mortise.mortise_product_sku
+UNION ALL
+SELECT 'row_count', 'mortise_product_sku_target', COUNT(*), COUNT(*) FILTER (WHERE del_flag = 0)
+FROM mortise.mortise_product_sku_target
+ORDER BY target_table;
+
+-- 2. status / is_default 分布
+SELECT 'status_distribution' AS check_type,
+ 'mortise_product_category' AS target_table,
+ status::TEXT AS status_value,
+ COUNT(*) AS row_count
+FROM mortise.mortise_product_category
+GROUP BY status
+UNION ALL
+SELECT 'status_distribution', 'mortise_product', status::TEXT, COUNT(*)
+FROM mortise.mortise_product
+GROUP BY status
+UNION ALL
+SELECT 'status_distribution', 'mortise_product_sku_target', status::TEXT, COUNT(*)
+FROM mortise.mortise_product_sku_target
+GROUP BY status
+UNION ALL
+SELECT 'is_default_distribution', 'mortise_product_sku', COALESCE(is_default::TEXT, 'null'), COUNT(*)
+FROM mortise.mortise_product_sku
+GROUP BY is_default
+ORDER BY target_table, status_value;
+
+-- 3. 结构与语义校验
+SELECT 'product_category_status_active_mismatch' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_product_category
+WHERE status IN (0, 1)
+ AND (
+ (status = 1 AND COALESCE(is_active, FALSE) = FALSE) OR
+ (status = 0 AND COALESCE(is_active, FALSE) = TRUE)
+ );
+
+SELECT 'product_category_invalid_status' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_product_category
+WHERE status NOT IN (0, 1)
+ OR status IS NULL;
+
+SELECT 'product_sku_target_invalid_status' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_product_sku_target
+WHERE status NOT IN (0, 1)
+ OR status IS NULL;
+
+SELECT 'product_missing_category' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_product product
+LEFT JOIN mortise.mortise_product_category category ON category.id = product.category_id
+WHERE product.category_id IS NOT NULL
+ AND category.id IS NULL;
+
+SELECT 'sku_orphan_product' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_product_sku sku
+LEFT JOIN mortise.mortise_product product ON product.id = sku.product_id
+WHERE product.id IS NULL;
+
+SELECT 'sku_target_orphan_sku' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_product_sku_target target
+LEFT JOIN mortise.mortise_product_sku sku ON sku.id = target.product_sku_id
+WHERE sku.id IS NULL;
+
+SELECT 'multi_default_sku_per_product' AS check_name,
+ COUNT(*) AS issue_count
+FROM (
+ SELECT product_id
+ FROM mortise.mortise_product_sku
+ WHERE del_flag = 0
+ AND is_default = TRUE
+ GROUP BY product_id
+ HAVING COUNT(*) > 1
+ ) duplicates;
+
+SELECT 'sku_target_missing_target_type' AS check_name,
+ COUNT(*) AS issue_count
+FROM mortise.mortise_product_sku_target
+WHERE NULLIF(BTRIM(COALESCE(target_type, '')), '') IS NULL;
diff --git a/config/migration/product/legacy-status-remap.sql b/config/migration/product/legacy-status-remap.sql
new file mode 100644
index 00000000..f61d7e96
--- /dev/null
+++ b/config/migration/product/legacy-status-remap.sql
@@ -0,0 +1,38 @@
+-- 商品域旧库导入后状态修复脚本
+-- 用途:
+-- 1. 把 old-codes 中 0=正常,1=禁用 的 ProductCategory / ProductSkuTarget 状态翻转为新语义 0=禁用,1=启用
+-- 2. 把 ProductCategory.is_active 校正到与新语义一致
+--
+-- 注意:
+-- 1. 该脚本不是 Flyway,不接入应用启动流程
+-- 2. 该脚本按“旧值尚未翻转”假设编写,重复执行会再次翻转 0/1
+-- 3. 执行前请先备份测试环境数据库
+
+BEGIN;
+
+-- 商品分类:旧语义 0=正常,1=禁用 -> 新语义 0=禁用,1=启用
+UPDATE mortise.mortise_product_category
+SET status = CASE status
+ WHEN 0 THEN 1
+ WHEN 1 THEN 0
+ ELSE status
+ END,
+ is_active = CASE status
+ WHEN 0 THEN TRUE
+ WHEN 1 THEN FALSE
+ ELSE COALESCE(is_active, FALSE)
+ END,
+ updated_time = CURRENT_TIMESTAMP
+WHERE status IN (0, 1);
+
+-- SKU 目标映射:旧语义 0=正常,1=禁用 -> 新语义 0=禁用,1=启用
+UPDATE mortise.mortise_product_sku_target
+SET status = CASE status
+ WHEN 0 THEN 1
+ WHEN 1 THEN 0
+ ELSE status
+ END,
+ updated_time = CURRENT_TIMESTAMP
+WHERE status IN (0, 1);
+
+COMMIT;
diff --git a/frontend/apps/admin/app/components/admin/PagedTableCard.vue b/frontend/apps/admin/app/components/admin/PagedTableCard.vue
index a18c0db2..14e950e7 100644
--- a/frontend/apps/admin/app/components/admin/PagedTableCard.vue
+++ b/frontend/apps/admin/app/components/admin/PagedTableCard.vue
@@ -1,31 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ slotProps.row[column.key] ?? '-' }}
+
+
+
+
+
+
+
diff --git a/frontend/apps/admin/app/components/dict-types/DictTypeDeleteModal.vue b/frontend/apps/admin/app/components/dict-types/DictTypeDeleteModal.vue
index a07a1772..cd72e2b8 100644
--- a/frontend/apps/admin/app/components/dict-types/DictTypeDeleteModal.vue
+++ b/frontend/apps/admin/app/components/dict-types/DictTypeDeleteModal.vue
@@ -17,7 +17,7 @@ const { remove, loading, errorMessage } = useAdminCrud(
)
async function handleConfirm() {
- const ok = await remove(props.dictType.id as number)
+ const ok = await remove(String(props.dictType.id))
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/dict-types/DictTypeEditModal.vue b/frontend/apps/admin/app/components/dict-types/DictTypeEditModal.vue
index 6945de85..5775f60c 100644
--- a/frontend/apps/admin/app/components/dict-types/DictTypeEditModal.vue
+++ b/frontend/apps/admin/app/components/dict-types/DictTypeEditModal.vue
@@ -30,7 +30,7 @@ async function handleConfirm() {
const valid = await formRef.value?.validate()
if (!valid) return
- const ok = await update(props.dictType.id as number, formData.value)
+ const ok = await update(String(props.dictType.id), formData.value)
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/dicts/DictDeleteModal.vue b/frontend/apps/admin/app/components/dicts/DictDeleteModal.vue
index 159b9e80..2b4e7813 100644
--- a/frontend/apps/admin/app/components/dicts/DictDeleteModal.vue
+++ b/frontend/apps/admin/app/components/dicts/DictDeleteModal.vue
@@ -17,7 +17,7 @@ const { remove, loading, errorMessage } = useAdminCrud(
)
async function handleConfirm() {
- const ok = await remove(props.dict.id as number)
+ const ok = await remove(String(props.dict.id))
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/dicts/DictEditModal.vue b/frontend/apps/admin/app/components/dicts/DictEditModal.vue
index 58b49229..4f7ea763 100644
--- a/frontend/apps/admin/app/components/dicts/DictEditModal.vue
+++ b/frontend/apps/admin/app/components/dicts/DictEditModal.vue
@@ -30,7 +30,7 @@ async function handleConfirm() {
const valid = await formRef.value?.validate()
if (!valid) return
- const ok = await update(props.dict.id as number, formData.value)
+ const ok = await update(String(props.dict.id), formData.value)
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/dynamic-form/DynamicFormField.vue b/frontend/apps/admin/app/components/dynamic-form/DynamicFormField.vue
index a31990fa..7c8a0ac4 100644
--- a/frontend/apps/admin/app/components/dynamic-form/DynamicFormField.vue
+++ b/frontend/apps/admin/app/components/dynamic-form/DynamicFormField.vue
@@ -5,7 +5,7 @@ import type { FormFieldDef } from '~/types'
* 通用动态表单字段渲染组件
*
* 根据字段类型(FormFieldType)渲染对应的表单控件,支持所有后端定义的字段类型:
- * TEXT / EMAIL / NUMBER / PASSWORD / BOOLEAN / SELECT / TEXTAREA / IMAGE
+ * TEXT / EMAIL / NUMBER / PASSWORD / BOOLEAN / SELECT / TEXTAREA / IMAGE / RICH_TEXT / HTML
*
* IMAGE 类型内置图片上传逻辑:选文件后自动上传至 /api/v1/admin/files,
* 上传成功后将文件 URL 通过 update:modelValue 事件传出。
@@ -23,6 +23,7 @@ const { $api: _api } = useNuxtApp()
const { resolveUrl } = useMediaUrl()
const toast = useToast()
const { uploadFile } = useAppFileUpload()
+const adminFilePickerProvider = useAdminFilePickerProvider()
// ─── PASSWORD 显示/隐藏 ────────────────────────────────────────────────────
const showPassword = ref(false)
@@ -176,6 +177,14 @@ function clearImage() {
@update:model-value="$emit('update:modelValue', $event)"
/>
+
+
()
const emit = defineEmits<{
diff --git a/frontend/apps/admin/app/components/members/MemberEditModal.vue b/frontend/apps/admin/app/components/members/MemberEditModal.vue
index 9d1228bc..e62fa27e 100644
--- a/frontend/apps/admin/app/components/members/MemberEditModal.vue
+++ b/frontend/apps/admin/app/components/members/MemberEditModal.vue
@@ -31,7 +31,7 @@ watch(
)
async function handleConfirm() {
- const id = props.member.id as number
+ const id = String(props.member.id)
if (selectedStatus.value === 1) {
const ok = await putAction(`${id}/enable`)
if (ok) {
diff --git a/frontend/apps/admin/app/components/menus/MenuDeleteModal.vue b/frontend/apps/admin/app/components/menus/MenuDeleteModal.vue
index 29cfc64c..ae5c4e5b 100644
--- a/frontend/apps/admin/app/components/menus/MenuDeleteModal.vue
+++ b/frontend/apps/admin/app/components/menus/MenuDeleteModal.vue
@@ -15,7 +15,7 @@ const emit = defineEmits<{
const { remove, loading, errorMessage } = useAdminCrud('/api/v1/admin/menus')
async function handleConfirm() {
- const ok = await remove(props.menu.id as number)
+ const ok = await remove(String(props.menu.id))
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/menus/MenuEditModal.vue b/frontend/apps/admin/app/components/menus/MenuEditModal.vue
index ac38b36e..3024a442 100644
--- a/frontend/apps/admin/app/components/menus/MenuEditModal.vue
+++ b/frontend/apps/admin/app/components/menus/MenuEditModal.vue
@@ -28,7 +28,7 @@ async function handleConfirm() {
const valid = await formRef.value?.validate()
if (!valid) return
- const ok = await update(props.menu.id as number, formData.value)
+ const ok = await update(String(props.menu.id), formData.value)
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/menus/MenuForm.vue b/frontend/apps/admin/app/components/menus/MenuForm.vue
index df1cc6d5..6d7364bd 100644
--- a/frontend/apps/admin/app/components/menus/MenuForm.vue
+++ b/frontend/apps/admin/app/components/menus/MenuForm.vue
@@ -27,7 +27,7 @@ const schema = z.object({
href: z.string().optional(),
menuType: z.coerce.number().default(0),
status: z.coerce.number().default(1),
- parentId: z.union([z.string(), z.number()]).optional(),
+ parentId: z.string().optional(),
sortNo: z.coerce.number().default(0)
})
@@ -49,13 +49,13 @@ const state = reactive({
href: '',
menuType: 0,
status: 1,
- parentId: undefined as string | number | undefined,
+ parentId: undefined as string | undefined,
sortNo: 0,
...props.data
})
// 加载菜单树作为父级菜单选项
-const parentMenuItems = ref>(
+const parentMenuItems = ref>(
[]
)
const noneParentValue = '__none__'
@@ -81,7 +81,7 @@ async function loadMenuTree() {
$api,
'/api/v1/admin/menus/tree'
)
- const items: Array<{ label: string; value: string | number }> = [
+ const items: Array<{ label: string; value: string }> = [
{ label: '根菜单', value: '0' }
]
function flatten(nodes: Array>, depth = 0) {
@@ -89,7 +89,7 @@ async function loadMenuTree() {
const prefix = depth > 0 ? ' '.repeat(depth) + '└ ' : ''
items.push({
label: `${prefix}${node.label}`,
- value: node.id as string | number
+ value: String(node.id)
})
if (Array.isArray(node.children) && node.children.length > 0) {
flatten(node.children as Array>, depth + 1)
diff --git a/frontend/apps/admin/app/components/menus/MenuTreeRow.vue b/frontend/apps/admin/app/components/menus/MenuTreeRow.vue
index fa1a8abe..6c5b39fe 100644
--- a/frontend/apps/admin/app/components/menus/MenuTreeRow.vue
+++ b/frontend/apps/admin/app/components/menus/MenuTreeRow.vue
@@ -5,7 +5,7 @@
*/
interface MenuTreeItem {
- id: number | string
+ id: string
label?: string
permission?: string
href?: string
@@ -28,13 +28,13 @@ type BadgeColor =
const props = defineProps<{
node: MenuTreeItem
depth: number
- expandedIds: Set
+ expandedIds: Set
formatMenuType: (type?: number) => string
menuTypeBadgeColor: (type?: number) => BadgeColor
}>()
const emit = defineEmits<{
- (e: 'toggle-expand', id: string | number): void
+ (e: 'toggle-expand', id: string): void
(e: 'edit' | 'delete', row: MenuTreeItem): void
}>()
diff --git a/frontend/apps/admin/app/components/oauth2-clients/Oauth2ClientDeleteModal.vue b/frontend/apps/admin/app/components/oauth2-clients/Oauth2ClientDeleteModal.vue
index 4d52ce1f..ce591416 100644
--- a/frontend/apps/admin/app/components/oauth2-clients/Oauth2ClientDeleteModal.vue
+++ b/frontend/apps/admin/app/components/oauth2-clients/Oauth2ClientDeleteModal.vue
@@ -17,7 +17,7 @@ const { remove, loading, errorMessage } = useAdminCrud(
)
async function handleConfirm() {
- const ok = await remove(props.client.id as number)
+ const ok = await remove(String(props.client.id))
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/oauth2-clients/Oauth2ClientEditModal.vue b/frontend/apps/admin/app/components/oauth2-clients/Oauth2ClientEditModal.vue
index 8889fa9f..566ff179 100644
--- a/frontend/apps/admin/app/components/oauth2-clients/Oauth2ClientEditModal.vue
+++ b/frontend/apps/admin/app/components/oauth2-clients/Oauth2ClientEditModal.vue
@@ -30,7 +30,7 @@ async function handleConfirm() {
const valid = await formRef.value?.validate()
if (!valid) return
- const ok = await update(props.client.id as number, formData.value)
+ const ok = await update(String(props.client.id), formData.value)
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/product-categories/ProductCategoryDeleteModal.vue b/frontend/apps/admin/app/components/product-categories/ProductCategoryDeleteModal.vue
index 9c6b6f94..7d89cbc9 100644
--- a/frontend/apps/admin/app/components/product-categories/ProductCategoryDeleteModal.vue
+++ b/frontend/apps/admin/app/components/product-categories/ProductCategoryDeleteModal.vue
@@ -17,7 +17,7 @@ const { remove, loading, errorMessage } = useAdminCrud(
)
async function handleConfirm() {
- const ok = await remove(props.category.id as number)
+ const ok = await remove(String(props.category.id))
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/product-categories/ProductCategoryEditModal.vue b/frontend/apps/admin/app/components/product-categories/ProductCategoryEditModal.vue
index f3ed38bd..b7635740 100644
--- a/frontend/apps/admin/app/components/product-categories/ProductCategoryEditModal.vue
+++ b/frontend/apps/admin/app/components/product-categories/ProductCategoryEditModal.vue
@@ -30,7 +30,7 @@ async function handleConfirm() {
const valid = await formRef.value?.validate()
if (!valid) return
- const ok = await update(props.category.id as number, formData.value)
+ const ok = await update(String(props.category.id), formData.value)
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/product-categories/ProductCategoryForm.vue b/frontend/apps/admin/app/components/product-categories/ProductCategoryForm.vue
index cc29343b..5f9d322d 100644
--- a/frontend/apps/admin/app/components/product-categories/ProductCategoryForm.vue
+++ b/frontend/apps/admin/app/components/product-categories/ProductCategoryForm.vue
@@ -34,7 +34,7 @@ const state = reactive({
name: '',
slug: '',
description: '',
- parentId: null as number | null,
+ parentId: null as string | null,
sortNo: 0,
status: 1,
...props.data
diff --git a/frontend/apps/admin/app/components/product-sku-targets/TargetManagerSlideover.vue b/frontend/apps/admin/app/components/product-sku-targets/TargetManagerSlideover.vue
new file mode 100644
index 00000000..a1a888ac
--- /dev/null
+++ b/frontend/apps/admin/app/components/product-sku-targets/TargetManagerSlideover.vue
@@ -0,0 +1,423 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 当前 SKU:{{ sku?.skuCode || sku?.id || '-' }}
+
+
+
+ 刷新
+
+
+ 新增映射
+
+
+
+
+
+
+
+
+
+
{{ target.targetType || '-' }}
+
+ {{ target.status === 1 ? '启用' : '禁用' }}
+
+
+
+
目标 ID:{{ target.targetId || '-' }}
+
数量:{{ target.quantity ?? '-' }}
+
有效期:{{ target.validityDays ?? '长期' }}
+
权限:{{ target.accessLevel || '-' }}
+
更新时间:{{ target.updatedTime || '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
Conditions
+
{{ target.conditions ? JSON.stringify(target.conditions, null, 2) : '{}' }}
+
+
+
Metadata
+
{{ target.metadata ? JSON.stringify(target.metadata, null, 2) : '{}' }}
+
+
+
+
+
+ 当前 SKU 暂无目标映射
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+
+
+ 保存
+
+
+
+
+
+
+
+
diff --git a/frontend/apps/admin/app/components/product-skus/ProductSkuAddModal.vue b/frontend/apps/admin/app/components/product-skus/ProductSkuAddModal.vue
index 6433f005..959aca7e 100644
--- a/frontend/apps/admin/app/components/product-skus/ProductSkuAddModal.vue
+++ b/frontend/apps/admin/app/components/product-skus/ProductSkuAddModal.vue
@@ -6,7 +6,7 @@
const open = defineModel('open', { default: false })
const props = defineProps<{
- productId: number | string
+ productId: string
}>()
const emit = defineEmits<{
diff --git a/frontend/apps/admin/app/components/product-skus/ProductSkuDeleteModal.vue b/frontend/apps/admin/app/components/product-skus/ProductSkuDeleteModal.vue
index 2c235600..c96c193e 100644
--- a/frontend/apps/admin/app/components/product-skus/ProductSkuDeleteModal.vue
+++ b/frontend/apps/admin/app/components/product-skus/ProductSkuDeleteModal.vue
@@ -5,7 +5,7 @@
const open = defineModel('open', { default: false })
const props = defineProps<{
- productId: number | string
+ productId: string
sku: Record
}>()
diff --git a/frontend/apps/admin/app/components/products/ProductDeleteModal.vue b/frontend/apps/admin/app/components/products/ProductDeleteModal.vue
index 3263cf9c..20a81a47 100644
--- a/frontend/apps/admin/app/components/products/ProductDeleteModal.vue
+++ b/frontend/apps/admin/app/components/products/ProductDeleteModal.vue
@@ -15,7 +15,7 @@ const emit = defineEmits<{
const { remove, loading, errorMessage } = useAdminCrud('/api/v1/admin/products')
async function handleConfirm() {
- const ok = await remove(props.product.id as number)
+ const ok = await remove(String(props.product.id))
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/products/ProductEditModal.vue b/frontend/apps/admin/app/components/products/ProductEditModal.vue
index c54031ec..e18d0080 100644
--- a/frontend/apps/admin/app/components/products/ProductEditModal.vue
+++ b/frontend/apps/admin/app/components/products/ProductEditModal.vue
@@ -28,7 +28,7 @@ async function handleConfirm() {
const valid = await formRef.value?.validate()
if (!valid) return
- const ok = await update(props.product.id as number, formData.value)
+ const ok = await update(String(props.product.id), formData.value)
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/products/ProductForm.vue b/frontend/apps/admin/app/components/products/ProductForm.vue
index 08ba7a53..e35d1c37 100644
--- a/frontend/apps/admin/app/components/products/ProductForm.vue
+++ b/frontend/apps/admin/app/components/products/ProductForm.vue
@@ -32,6 +32,7 @@ const emit = defineEmits<{
const { $api } = useNuxtApp()
const toast = useToast()
const { uploadFile } = useAppFileUpload()
+const adminFilePickerProvider = useAdminFilePickerProvider()
const schema = z.object({
title: z.string().min(1, '请输入产品标题'),
@@ -81,34 +82,6 @@ function clearCoverImage() {
state.coverImageUrl = ''
}
-// ─── 编辑器工具栏 ──────────────────────────────────────────────────────────────
-const editorToolbarItems = [
- [
- { kind: 'heading', level: 1, icon: 'i-lucide-heading-1', tooltip: { text: '标题 1' } },
- { kind: 'heading', level: 2, icon: 'i-lucide-heading-2', tooltip: { text: '标题 2' } },
- { kind: 'heading', level: 3, icon: 'i-lucide-heading-3', tooltip: { text: '标题 3' } },
- { kind: 'paragraph', icon: 'i-lucide-pilcrow', tooltip: { text: '正文' } }
- ],
- [
- { kind: 'mark', mark: 'bold', icon: 'i-lucide-bold', tooltip: { text: '粗体' } },
- { kind: 'mark', mark: 'italic', icon: 'i-lucide-italic', tooltip: { text: '斜体' } },
- { kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough', tooltip: { text: '删除线' } },
- { kind: 'mark', mark: 'code', icon: 'i-lucide-code', tooltip: { text: '行内代码' } }
- ],
- [
- { kind: 'bulletList', icon: 'i-lucide-list', tooltip: { text: '无序列表' } },
- { kind: 'orderedList', icon: 'i-lucide-list-ordered', tooltip: { text: '有序列表' } },
- { kind: 'blockquote', icon: 'i-lucide-quote', tooltip: { text: '引用' } },
- { kind: 'codeBlock', icon: 'i-lucide-square-code', tooltip: { text: '代码块' } },
- { kind: 'horizontalRule', icon: 'i-lucide-minus', tooltip: { text: '水平线' } }
- ],
- [
- { kind: 'undo', icon: 'i-lucide-undo-2', tooltip: { text: '撤销' } },
- { kind: 'redo', icon: 'i-lucide-redo-2', tooltip: { text: '重做' } },
- { kind: 'clearFormatting', icon: 'i-lucide-remove-formatting', tooltip: { text: '清除格式' } }
- ]
-]
-
// 加载产品类型列表
const productTypeItems = ref([])
async function loadProductTypes() {
@@ -242,22 +215,11 @@ defineExpose({ validate, state })
-
-
-
-
-
-
-
+
diff --git a/frontend/apps/admin/app/components/roles/MenuTreeNode.vue b/frontend/apps/admin/app/components/roles/MenuTreeNode.vue
index 598e034c..fe1134ea 100644
--- a/frontend/apps/admin/app/components/roles/MenuTreeNode.vue
+++ b/frontend/apps/admin/app/components/roles/MenuTreeNode.vue
@@ -5,7 +5,7 @@
*/
interface MenuTreeItem {
- id: string | number
+ id: string
label?: string
permission?: string
icon?: string
@@ -16,8 +16,8 @@ interface MenuTreeItem {
const props = defineProps<{
node: MenuTreeItem
depth: number
- checkedIds: Set
- expandedIds: Set
+ checkedIds: Set
+ expandedIds: Set
isIndeterminate: (node: MenuTreeItem) => boolean
}>()
diff --git a/frontend/apps/admin/app/components/roles/RoleDeleteModal.vue b/frontend/apps/admin/app/components/roles/RoleDeleteModal.vue
index 53f15704..5e9432c9 100644
--- a/frontend/apps/admin/app/components/roles/RoleDeleteModal.vue
+++ b/frontend/apps/admin/app/components/roles/RoleDeleteModal.vue
@@ -15,7 +15,7 @@ const emit = defineEmits<{
const { remove, loading, errorMessage } = useAdminCrud('/api/v1/admin/roles')
async function handleConfirm() {
- const ok = await remove(props.role.id as number)
+ const ok = await remove(String(props.role.id))
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/roles/RoleEditModal.vue b/frontend/apps/admin/app/components/roles/RoleEditModal.vue
index 28916750..8b93e8f9 100644
--- a/frontend/apps/admin/app/components/roles/RoleEditModal.vue
+++ b/frontend/apps/admin/app/components/roles/RoleEditModal.vue
@@ -28,7 +28,7 @@ async function handleConfirm() {
const valid = await formRef.value?.validate()
if (!valid) return
- const ok = await update(props.role.id as number, formData.value)
+ const ok = await update(String(props.role.id), formData.value)
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/roles/RoleMenuModal.vue b/frontend/apps/admin/app/components/roles/RoleMenuModal.vue
index 7c04b7ba..e8ef1878 100644
--- a/frontend/apps/admin/app/components/roles/RoleMenuModal.vue
+++ b/frontend/apps/admin/app/components/roles/RoleMenuModal.vue
@@ -8,7 +8,7 @@
import { fetchAdminGet, fetchAdminPut } from '@mortise/core-sdk'
interface MenuTreeItem {
- id: string | number
+ id: string
label?: string
permission?: string
icon?: string
@@ -38,8 +38,8 @@ const loading = ref(false)
const dataLoading = ref(false)
const errorMessage = ref('')
const menuTree = ref([])
-const checkedIds = ref>(new Set())
-const expandedIds = ref>(new Set())
+const checkedIds = ref>(new Set())
+const expandedIds = ref>(new Set())
// ============ 工具方法 ============
@@ -70,7 +70,7 @@ function collectSubtree(node: MenuTreeItem): MenuTreeItem[] {
/** 查找目标节点的所有祖先节点 */
function findAncestors(
tree: MenuTreeItem[],
- targetId: string | number
+ targetId: string
): MenuTreeItem[] {
const path: MenuTreeItem[] = []
const find = (nodes: MenuTreeItem[], stack: MenuTreeItem[]): boolean => {
@@ -104,15 +104,15 @@ async function loadData() {
])
menuTree.value = tree || []
// 初始化选中状态:仅保留菜单树中存在的 ID
- const allIds = new Set(flattenTree(menuTree.value).map((m) => m.id))
+ const allIds = new Set(flattenTree(menuTree.value).map((m) => String(m.id)))
checkedIds.value = new Set(
- (roleMenus || []).filter((m) => allIds.has(m.id)).map((m) => m.id)
+ (roleMenus || []).filter((m) => allIds.has(String(m.id))).map((m) => String(m.id))
)
// 默认展开所有节点
expandedIds.value = new Set(
flattenTree(menuTree.value)
.filter((m) => m.children?.length)
- .map((m) => m.id)
+ .map((m) => String(m.id))
)
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : '加载菜单失败'
@@ -147,9 +147,9 @@ function toggleMenu(node: MenuTreeItem) {
/** 向上检查:如果某个节点的所有子节点都已选中,则自动选中该节点 */
function autoCheckAncestors(
- ids: Set,
+ ids: Set,
tree: MenuTreeItem[],
- targetId: string | number
+ targetId: string
) {
const ancestors = findAncestors(tree, targetId)
for (let i = ancestors.length - 1; i >= 0; i--) {
diff --git a/frontend/apps/admin/app/components/roles/RoleUserModal.vue b/frontend/apps/admin/app/components/roles/RoleUserModal.vue
index 04c3553a..5a5ba6ad 100644
--- a/frontend/apps/admin/app/components/roles/RoleUserModal.vue
+++ b/frontend/apps/admin/app/components/roles/RoleUserModal.vue
@@ -6,7 +6,7 @@
import { fetchAdminGet, fetchAdminPut, fetchAdminPage } from '@mortise/core-sdk'
interface UserItem {
- id: string | number
+ id: string
account?: string
nickname?: string
email?: string
@@ -34,7 +34,7 @@ const loading = ref(false)
const dataLoading = ref(false)
const errorMessage = ref('')
const allUsers = ref([])
-const checkedIds = ref>(new Set())
+const checkedIds = ref>(new Set())
// 分页搜索
const searchKeyword = ref('')
@@ -75,7 +75,7 @@ async function loadData() {
hasNext.value = Boolean(pageResult.hasNext)
// 仅首次打开时初始化选中状态
if (checkedIds.value.size === 0) {
- checkedIds.value = new Set((roleUsers || []).map((u) => u.id))
+ checkedIds.value = new Set((roleUsers || []).map((u) => String(u.id)))
}
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : '加载用户失败'
@@ -116,7 +116,7 @@ async function loadUsers() {
}
}
-function toggleUser(id: string | number) {
+function toggleUser(id: string) {
const s = new Set(checkedIds.value)
if (s.has(id)) {
s.delete(id)
diff --git a/frontend/apps/admin/app/components/users/UserDeleteModal.vue b/frontend/apps/admin/app/components/users/UserDeleteModal.vue
index ffbcfdef..befc87f4 100644
--- a/frontend/apps/admin/app/components/users/UserDeleteModal.vue
+++ b/frontend/apps/admin/app/components/users/UserDeleteModal.vue
@@ -9,7 +9,7 @@ const { loading, remove } = useAdminCrud('/api/v1/admin/users')
const open = ref(false)
async function onConfirm() {
- const ok = await remove(props.user.id as number)
+ const ok = await remove(String(props.user.id))
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/users/UserEditModal.vue b/frontend/apps/admin/app/components/users/UserEditModal.vue
index 7c24dd9d..b413ca4b 100644
--- a/frontend/apps/admin/app/components/users/UserEditModal.vue
+++ b/frontend/apps/admin/app/components/users/UserEditModal.vue
@@ -20,7 +20,7 @@ async function onSubmit() {
const valid = await formRef.value?.validate()
if (!valid) return
- const result = await update(props.user.id as number, formRef.value!.state)
+ const result = await update(String(props.user.id), formRef.value!.state)
if (result !== null) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/users/UserRoleModal.vue b/frontend/apps/admin/app/components/users/UserRoleModal.vue
index 890aad6f..584ed692 100644
--- a/frontend/apps/admin/app/components/users/UserRoleModal.vue
+++ b/frontend/apps/admin/app/components/users/UserRoleModal.vue
@@ -10,7 +10,7 @@ import {
} from '@mortise/core-sdk'
interface RoleItem {
- id: string | number
+ id: string
label?: string
permission?: string
status?: number
@@ -37,7 +37,7 @@ const loading = ref(false)
const rolesLoading = ref(false)
const errorMessage = ref('')
const allRoles = ref([])
-const checkedIds = ref>(new Set())
+const checkedIds = ref>(new Set())
/** 加载所有角色 + 当前用户已绑定角色 */
async function loadData() {
@@ -57,7 +57,7 @@ async function loadData() {
)
])
allRoles.value = pageResult.records || []
- checkedIds.value = new Set((userRoles || []).map((r) => r.id))
+ checkedIds.value = new Set((userRoles || []).map((r) => String(r.id)))
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : '加载角色失败'
} finally {
@@ -65,7 +65,7 @@ async function loadData() {
}
}
-function toggleRole(id: string | number) {
+function toggleRole(id: string) {
const s = new Set(checkedIds.value)
if (s.has(id)) {
s.delete(id)
diff --git a/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountDeleteModal.vue b/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountDeleteModal.vue
index e70f97a6..5bf5a23f 100644
--- a/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountDeleteModal.vue
+++ b/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountDeleteModal.vue
@@ -17,7 +17,7 @@ const { remove, loading, errorMessage } = useAdminCrud(
)
async function handleConfirm() {
- const ok = await remove(props.account.id as number)
+ const ok = await remove(String(props.account.id))
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountEditModal.vue b/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountEditModal.vue
index 406c2767..aeb67302 100644
--- a/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountEditModal.vue
+++ b/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountEditModal.vue
@@ -30,7 +30,7 @@ async function handleConfirm() {
const valid = await formRef.value?.validate()
if (!valid) return
- const ok = await update(props.account.id as number, formData.value)
+ const ok = await update(String(props.account.id), formData.value)
if (ok) {
open.value = false
emit('success')
diff --git a/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountMenuModal.vue b/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountMenuModal.vue
new file mode 100644
index 00000000..24d1eecc
--- /dev/null
+++ b/frontend/apps/admin/app/components/wechat-accounts/WeChatAccountMenuModal.vue
@@ -0,0 +1,419 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ account.accountName || account.appId || '微信账号' }}
+
+
+ {{ account.appId }}
+
+
+
+
+ 读取菜单
+
+
+ 一级菜单
+
+
+
+
+
+
+
+
一级菜单
+
{{ menuButtons.length }} / {{ rootLimit }}
+
+
+
+ 暂无菜单
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 请选择菜单
+
+
+
+
+
菜单内容
+
+
+ 子菜单
+
+
+ 移除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/apps/admin/app/composables/useAdminCrud.ts b/frontend/apps/admin/app/composables/useAdminCrud.ts
index ea8b3f7c..2aa77fe8 100644
--- a/frontend/apps/admin/app/composables/useAdminCrud.ts
+++ b/frontend/apps/admin/app/composables/useAdminCrud.ts
@@ -33,7 +33,7 @@ export function useAdminCrud(basePath: string) {
/** 更新 */
async function update(
- id: string | number,
+ id: string,
data: Record
): Promise {
loading.value = true
@@ -49,7 +49,7 @@ export function useAdminCrud(basePath: string) {
}
/** 删除 */
- async function remove(id: string | number): Promise {
+ async function remove(id: string): Promise {
loading.value = true
errorMessage.value = ''
try {
@@ -64,7 +64,7 @@ export function useAdminCrud(basePath: string) {
}
/** 批量删除 */
- async function batchRemove(ids: (string | number)[]): Promise {
+ async function batchRemove(ids: string[]): Promise {
loading.value = true
errorMessage.value = ''
try {
diff --git a/frontend/apps/admin/app/composables/useAdminFilePickerProvider.ts b/frontend/apps/admin/app/composables/useAdminFilePickerProvider.ts
new file mode 100644
index 00000000..fb6fd957
--- /dev/null
+++ b/frontend/apps/admin/app/composables/useAdminFilePickerProvider.ts
@@ -0,0 +1,222 @@
+import { fetchAdminPage } from '@mortise/core-sdk'
+import type { CompatMediaPickerProvider } from '~/types/media-picker'
+
+export interface AdminFilePickerRecord {
+ id: string
+ url?: string
+ size?: number
+ filename?: string
+ originalFilename?: string
+ ext?: string
+ contentType?: string
+ platform?: string
+ thUrl?: string
+ createTime?: string
+}
+
+function isRecord(value: unknown): value is Record {
+ return !!value && typeof value === 'object' && !Array.isArray(value)
+}
+
+function toOptionalString(value: unknown): string | undefined {
+ if (typeof value === 'string' && value.trim()) {
+ return value.trim()
+ }
+
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return String(value)
+ }
+
+ return undefined
+}
+
+function toOptionalNumber(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value
+ }
+
+ if (typeof value === 'string' && value.trim()) {
+ const parsed = Number(value)
+ if (Number.isFinite(parsed)) {
+ return parsed
+ }
+ }
+
+ return undefined
+}
+
+function getFileLabel(record: AdminFilePickerRecord): string {
+ return record.originalFilename || record.filename || record.id
+}
+
+function formatFileSize(size?: number): string {
+ if (!size || size <= 0) {
+ return ''
+ }
+
+ if (size < 1024) {
+ return `${size} B`
+ }
+
+ if (size < 1024 * 1024) {
+ return `${(size / 1024).toFixed(1)} KB`
+ }
+
+ if (size < 1024 * 1024 * 1024) {
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`
+ }
+
+ return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`
+}
+
+export function normalizeAdminFilePickerRecord(value: unknown): AdminFilePickerRecord | null {
+ if (!isRecord(value)) {
+ return null
+ }
+
+ const id = toOptionalString(value.id)
+ if (!id) {
+ return null
+ }
+
+ return {
+ id,
+ url: toOptionalString(value.url),
+ size: toOptionalNumber(value.size),
+ filename: toOptionalString(value.filename),
+ originalFilename: toOptionalString(value.originalFilename),
+ ext: toOptionalString(value.ext),
+ contentType: toOptionalString(value.contentType),
+ platform: toOptionalString(value.platform),
+ thUrl: toOptionalString(value.thUrl),
+ createTime: toOptionalString(value.createTime)
+ }
+}
+
+export function isAdminFilePickerImage(record: Pick): boolean {
+ if (record.contentType?.startsWith('image/')) {
+ return true
+ }
+
+ return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(
+ (record.ext || '').toLowerCase()
+ )
+}
+
+function getFileIcon(record: AdminFilePickerRecord): string {
+ if (isAdminFilePickerImage(record)) {
+ return 'i-lucide-image'
+ }
+
+ if (record.contentType?.startsWith('video/')) {
+ return 'i-lucide-film'
+ }
+
+ if (record.contentType?.startsWith('audio/')) {
+ return 'i-lucide-audio-lines'
+ }
+
+ if (record.contentType?.includes('json')) {
+ return 'i-lucide-braces'
+ }
+
+ if (record.contentType?.includes('zip') || record.ext?.toLowerCase() === 'zip') {
+ return 'i-lucide-file-archive'
+ }
+
+ return 'i-lucide-file'
+}
+
+export function useAdminFilePickerProvider(): CompatMediaPickerProvider {
+ const { $api } = useNuxtApp()
+ const { resolveUrl } = useMediaUrl()
+
+ return {
+ searchPlaceholder: '搜索文件名 / 原始文件名',
+ emptyText: '当前筛选条件下暂无可选文件',
+ async load(context) {
+ const page = await fetchAdminPage($api, '/api/v1/admin/files', {
+ pageNumber: context.pageNum,
+ pageSize: 12,
+ keyword: context.keyword || undefined
+ })
+
+ const records = Array.isArray(page.records)
+ ? page.records
+ .map(record => normalizeAdminFilePickerRecord(record))
+ .filter((record): record is AdminFilePickerRecord => !!record)
+ : []
+
+ return {
+ records,
+ total: Number(page.totalRow || 0),
+ totalPage: Number(page.totalPage || 0),
+ hasNext: Boolean(page.hasNext),
+ hasPrevious: Boolean(page.hasPrevious)
+ }
+ },
+ toSelection(record) {
+ const file = normalizeAdminFilePickerRecord(record)
+ if (!file) {
+ return {
+ id: '',
+ label: '未知文件',
+ raw: record
+ }
+ }
+
+ return {
+ id: file.id,
+ label: getFileLabel(file),
+ url: file.url,
+ fileType: file.contentType,
+ fileExtension: file.ext,
+ raw: file
+ }
+ },
+ getKey(record) {
+ return normalizeAdminFilePickerRecord(record)?.id || ''
+ },
+ getLabel(record) {
+ const file = normalizeAdminFilePickerRecord(record)
+ return file ? getFileLabel(file) : '未知文件'
+ },
+ getPreviewUrl(record) {
+ const file = normalizeAdminFilePickerRecord(record)
+ const url = file?.thUrl || file?.url
+ return resolveUrl(url) || url || ''
+ },
+ isPreviewImage(record) {
+ const file = normalizeAdminFilePickerRecord(record)
+ return file ? isAdminFilePickerImage(file) : false
+ },
+ getIcon(record) {
+ const file = normalizeAdminFilePickerRecord(record)
+ return file ? getFileIcon(file) : 'i-lucide-file'
+ },
+ getBadge(record) {
+ const file = normalizeAdminFilePickerRecord(record)
+ if (!file?.platform) {
+ return null
+ }
+
+ return {
+ label: file.platform,
+ color: 'neutral'
+ }
+ },
+ getMetaLines(record) {
+ const file = normalizeAdminFilePickerRecord(record)
+ if (!file) {
+ return []
+ }
+
+ const sizeLabel = formatFileSize(file.size)
+ const typeLine = [file.contentType, sizeLabel].filter(Boolean).join(' · ')
+
+ return [typeLine, file.filename || file.id, file.createTime].filter(
+ (line): line is string => Boolean(line)
+ )
+ }
+ }
+}
diff --git a/frontend/apps/admin/app/pages/agent/models.vue b/frontend/apps/admin/app/pages/agent/models.vue
new file mode 100644
index 00000000..3a54b29f
--- /dev/null
+++ b/frontend/apps/admin/app/pages/agent/models.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/frontend/apps/admin/app/pages/agent/providers.vue b/frontend/apps/admin/app/pages/agent/providers.vue
new file mode 100644
index 00000000..ea631f64
--- /dev/null
+++ b/frontend/apps/admin/app/pages/agent/providers.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/frontend/apps/admin/app/pages/inbox.vue b/frontend/apps/admin/app/pages/inbox.vue
index cf07c0f1..513bcf96 100644
--- a/frontend/apps/admin/app/pages/inbox.vue
+++ b/frontend/apps/admin/app/pages/inbox.vue
@@ -41,7 +41,7 @@ function mapRole(role: string): 'user' | 'assistant' {
const sessions= ref([])
const sessionsLoading = ref(false)
const sessionsError = ref('')
-const selectedSessionId = ref(null)
+const selectedSessionId = ref(null)
const selectedSession = computed(
() => sessions.value.find((s) => s.id === selectedSessionId.value) ?? null
@@ -57,13 +57,13 @@ async function loadSessions() {
)
if (res.code === 200) {
sessions.value = (res.data?.records ?? []).map((s) => ({
- id: s.id,
- userId: s.userId,
+ id: String(s.id),
+ userId: String(s.userId),
userName: s.userName,
userAvatar: s.userAvatar,
status: s.status as 0 | 1 | 2,
contextType: s.contextType,
- contextId: s.contextId,
+ contextId: s.contextId == null ? null : String(s.contextId),
contextTitle: s.contextTitle,
lastMessage: s.lastMessage,
unreadCount: s.unreadCount,
@@ -93,7 +93,7 @@ async function selectSession(session: ChatSession) {
// ── 消息记录 ──────────────────────────────────────────────────────────────────
-const messagesMap = ref>({})
+const messagesMap = ref>({})
const messagesLoading = ref(false)
const currentMessages = computed(() =>
@@ -102,7 +102,7 @@ const currentMessages = computed(() =>
: []
)
-async function loadMessages(sessionId: number) {
+async function loadMessages(sessionId: string) {
messagesLoading.value = true
try {
const res = await $api>>(
@@ -112,11 +112,11 @@ async function loadMessages(sessionId: number) {
if (res.code === 200) {
messagesMap.value[sessionId] = (res.data?.records ?? []).map((m) => ({
id: String(m.id),
- sessionId: m.sessionId,
+ sessionId: String(m.sessionId),
role: mapRole(m.role),
parts: [{ type: 'text' as const, text: m.content }],
time: formatTime(m.createdTime),
- senderId: m.senderId
+ senderId: m.senderId == null ? null : String(m.senderId)
}))
}
} catch {
@@ -174,11 +174,11 @@ async function sendReply() {
if (res.code === 200 && res.data) {
const msg: InboxChatMessage = {
id: String(res.data.id),
- sessionId: res.data.sessionId,
+ sessionId: String(res.data.sessionId),
role: 'assistant',
parts: [{ type: 'text', text: res.data.content }],
time: formatTime(res.data.createdTime),
- senderId: res.data.senderId
+ senderId: res.data.senderId == null ? null : String(res.data.senderId)
}
if (!messagesMap.value[currentSessionId]) {
messagesMap.value[currentSessionId] = []
diff --git a/frontend/apps/admin/app/pages/members.vue b/frontend/apps/admin/app/pages/members.vue
index e30b6439..6ce9d1bf 100644
--- a/frontend/apps/admin/app/pages/members.vue
+++ b/frontend/apps/admin/app/pages/members.vue
@@ -2,7 +2,7 @@
import { usePagedAdminResource } from '~/composables/usePagedAdminResource'
interface MemberInfo {
- id: number
+ id: string
username?: string
email?: string
phone?: string
diff --git a/frontend/apps/admin/app/pages/products/[id]/skus.vue b/frontend/apps/admin/app/pages/products/[id]/skus.vue
index 7fd6d5df..ebb65d85 100644
--- a/frontend/apps/admin/app/pages/products/[id]/skus.vue
+++ b/frontend/apps/admin/app/pages/products/[id]/skus.vue
@@ -5,6 +5,12 @@
* 管理产品规格单元(SKU)的增删改查,以及设置默认 SKU、切换状态
*/
import { fetchAdminGet } from '@mortise/core-sdk'
+import type { ProductSkuInfo } from '~/types/migration'
+import {
+ isRecord,
+ toOptionalBoolean,
+ toOptionalString,
+} from '~/utils/migration'
const route = useRoute()
const productId = route.params.id as string
@@ -29,17 +35,49 @@ async function loadProduct() {
// ============ SKU 列表 ============
const loading = ref(false)
const error = ref('')
-const skus = ref[]>([])
+const skus = ref([])
+
+function normalizeSku(value: unknown): ProductSkuInfo | null {
+ if (!isRecord(value)) {
+ return null
+ }
+
+ const id = toOptionalString(value.id)
+ if (!id) {
+ return null
+ }
+
+ return {
+ id,
+ skuCode: toOptionalString(value.skuCode),
+ name: toOptionalString(value.name),
+ description: toOptionalString(value.description),
+ status: toOptionalString(value.status),
+ isDefault: toOptionalBoolean(value.isDefault),
+ attributes: isRecord(value.attributes) ? value.attributes : undefined,
+ createdTime: toOptionalString(value.createdTime)
+ }
+}
+
+function normalizeSkus(value: unknown): ProductSkuInfo[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+
+ return value
+ .map(item => normalizeSku(item))
+ .filter((item): item is ProductSkuInfo => !!item)
+}
async function loadSkus() {
loading.value = true
error.value = ''
try {
- const list = await fetchAdminGet[]>(
+ const list = await fetchAdminGet(
$api,
`/api/v1/admin/products/${productId}/skus`
)
- skus.value = list || []
+ skus.value = normalizeSkus(list)
} catch (err) {
error.value = err instanceof Error ? err.message : '加载规格失败'
} finally {
@@ -61,6 +99,8 @@ const showAddModal = ref(false)
const showEditModal = ref(false)
const showDeleteModal = ref(false)
const currentSku = ref>({})
+const targetSku = ref(null)
+const showTargetSlideover = ref(false)
function openEdit(sku: Record) {
currentSku.value = { ...sku }
@@ -72,26 +112,31 @@ function openDelete(sku: Record) {
showDeleteModal.value = true
}
+function openTargetManager(sku: ProductSkuInfo) {
+ targetSku.value = { ...sku }
+ showTargetSlideover.value = true
+}
+
// ============ 设为默认 ============
const { patchAction, loading: _patchLoading } = useAdminCrud(
`/api/v1/admin/products/${productId}/skus`
)
-const settingDefaultId = ref(null)
+const settingDefaultId = ref(null)
-async function setDefault(sku: Record) {
- settingDefaultId.value = sku.id as number
+async function setDefault(sku: ProductSkuInfo) {
+ settingDefaultId.value = sku.id
const ok = await patchAction(`${sku.id}/default`)
settingDefaultId.value = null
if (ok !== null) loadSkus()
}
// ============ 切换状态 ============
-const togglingStatusId = ref(null)
+const togglingStatusId = ref(null)
-async function toggleStatus(sku: Record) {
+async function toggleStatus(sku: ProductSkuInfo) {
const current = sku.status as string
const next = current === 'active' ? 'inactive' : 'active'
- togglingStatusId.value = sku.id as number
+ togglingStatusId.value = sku.id
const ok = await patchAction(`${sku.id}/status`, { status: next })
togglingStatusId.value = null
if (ok !== null) loadSkus()
@@ -193,12 +238,9 @@ async function toggleStatus(sku: Record) {
-
+
) {
color="neutral"
variant="ghost"
size="xs"
- @click="openEdit(sku as Record)"
+ @click="openEdit(sku as unknown as Record)"
+ />
+
) {
size="xs"
:loading="settingDefaultId === sku.id"
title="设为默认"
- @click="setDefault(sku as Record)"
+ @click="setDefault(sku)"
/>
) {
size="xs"
:loading="togglingStatusId === sku.id"
:title="sku.status === 'active' ? '点击下架' : '点击上架'"
- @click="toggleStatus(sku as Record)"
+ @click="toggleStatus(sku)"
/>
) {
color="error"
variant="ghost"
size="xs"
- @click="openDelete(sku as Record)"
+ @click="openDelete(sku as unknown as Record)"
/>
|
@@ -317,6 +367,12 @@ async function toggleStatus(sku: Record) {
:sku="currentSku"
@success="loadSkus"
/>
+
+
diff --git a/frontend/apps/admin/app/pages/setup.vue b/frontend/apps/admin/app/pages/setup.vue
index f88aca62..91eee280 100644
--- a/frontend/apps/admin/app/pages/setup.vue
+++ b/frontend/apps/admin/app/pages/setup.vue
@@ -13,8 +13,7 @@ useSeoMeta({
})
const { checkInitStatus, initializeSystem, getInitProgress } = useSystemInit()
-
-const router = useRouter()
+const initializedState = useState('system-initialized', () => null)
// 步骤状态
const currentStep = ref(0)
@@ -26,6 +25,7 @@ const isInitializing = ref(false)
const showAdminPassword = ref(false)
const showAdminPasswordConfirm = ref(false)
let progressTimer: ReturnType | null = null
+let redirectTimer: ReturnType | null = null
// 检查是否已初始化
const initialized = ref(false)
@@ -130,8 +130,16 @@ function stopProgressPolling() {
}
}
+function stopRedirectTimer() {
+ if (redirectTimer) {
+ clearTimeout(redirectTimer)
+ redirectTimer = null
+ }
+}
+
onUnmounted(() => {
stopProgressPolling()
+ stopRedirectTimer()
})
async function doInitialize() {
@@ -154,10 +162,12 @@ async function doInitialize() {
await initializeSystem(initInfo)
stopProgressPolling()
progress.value = 100
+ initialized.value = true
+ initializedState.value = true
successMessage.value = '系统初始化成功!即将跳转到登录页面...'
- setTimeout(() => {
- router.push('/auth/login')
+ redirectTimer = setTimeout(() => {
+ void navigateTo('/auth/login', { replace: true })
}, 2000)
} catch (error) {
stopProgressPolling()
diff --git a/frontend/apps/admin/app/pages/systems/dictionaries.vue b/frontend/apps/admin/app/pages/systems/dictionaries.vue
index 0fe8a90f..18dd7212 100644
--- a/frontend/apps/admin/app/pages/systems/dictionaries.vue
+++ b/frontend/apps/admin/app/pages/systems/dictionaries.vue
@@ -7,13 +7,13 @@ import { fetchAdminPage } from '@mortise/core-sdk'
import { usePagedAdminResource } from '~/composables/usePagedAdminResource'
interface DictTypeOption {
- id: number
+ id: string
typeCode: string
label: string
}
interface DictInfo {
- id: number
+ id: string
dictTypeCode?: string
label?: string
value?: string
diff --git a/frontend/apps/admin/app/pages/systems/menus.vue b/frontend/apps/admin/app/pages/systems/menus.vue
index b6a61b81..17d0c851 100644
--- a/frontend/apps/admin/app/pages/systems/menus.vue
+++ b/frontend/apps/admin/app/pages/systems/menus.vue
@@ -6,7 +6,7 @@
import { fetchAdminGet } from '@mortise/core-sdk'
interface MenuTreeItem {
- id: number | string
+ id: string
label?: string
permission?: string
href?: string
@@ -14,7 +14,7 @@ interface MenuTreeItem {
menuType?: number
sortNo?: number
status?: number
- parentId?: number | string
+ parentId?: string
children?: MenuTreeItem[]
}
@@ -23,7 +23,7 @@ const { $api } = useNuxtApp()
const loading = ref(false)
const errorMessage = ref('')
const menuTree = ref([])
-const expandedIds = ref>(new Set())
+const expandedIds = ref>(new Set())
const keyword = ref('')
async function loadMenus() {
@@ -36,11 +36,11 @@ async function loadMenus() {
)
menuTree.value = tree || []
// 默认展开所有含子节点的节点
- const ids = new Set()
+ const ids = new Set()
const walk = (nodes: MenuTreeItem[]) => {
for (const n of nodes) {
if (n.children?.length) {
- ids.add(n.id)
+ ids.add(String(n.id))
walk(n.children)
}
}
@@ -94,7 +94,7 @@ const filteredTree = computed(() => {
return filterNodes(menuTree.value)
})
-function toggleExpand(id: string | number) {
+function toggleExpand(id: string) {
const s = new Set(expandedIds.value)
if (s.has(id)) {
s.delete(id)
@@ -105,11 +105,11 @@ function toggleExpand(id: string | number) {
}
function expandAll() {
- const ids = new Set()
+ const ids = new Set()
const walk = (nodes: MenuTreeItem[]) => {
for (const n of nodes) {
if (n.children?.length) {
- ids.add(n.id)
+ ids.add(String(n.id))
walk(n.children)
}
}
diff --git a/frontend/apps/admin/app/pages/systems/roles.vue b/frontend/apps/admin/app/pages/systems/roles.vue
index 3777d1e1..779ab99b 100644
--- a/frontend/apps/admin/app/pages/systems/roles.vue
+++ b/frontend/apps/admin/app/pages/systems/roles.vue
@@ -2,7 +2,7 @@
import { usePagedAdminResource } from '~/composables/usePagedAdminResource'
interface RoleInfo {
- id: number
+ id: string
label?: string
permission?: string
status?: number
diff --git a/frontend/apps/admin/app/pages/systems/users.vue b/frontend/apps/admin/app/pages/systems/users.vue
index fd6efb35..deb6c17f 100644
--- a/frontend/apps/admin/app/pages/systems/users.vue
+++ b/frontend/apps/admin/app/pages/systems/users.vue
@@ -2,7 +2,7 @@
import { usePagedAdminResource } from '~/composables/usePagedAdminResource'
interface UserInfo {
- id: number
+ id: string
account?: string
nickname?: string
email?: string
diff --git a/frontend/apps/admin/app/pages/systems/wechat-accounts.vue b/frontend/apps/admin/app/pages/systems/wechat-accounts.vue
index 0291d1e4..934c18e6 100644
--- a/frontend/apps/admin/app/pages/systems/wechat-accounts.vue
+++ b/frontend/apps/admin/app/pages/systems/wechat-accounts.vue
@@ -1,8 +1,9 @@
@@ -118,6 +132,15 @@ function openDeleteModal(row: Record) {
+
+ 菜单
+
) {
:account="currentRow"
@success="loadData"
/>
+
diff --git a/frontend/apps/admin/app/types/inbox.ts b/frontend/apps/admin/app/types/inbox.ts
index dee59454..f7f12fc5 100644
--- a/frontend/apps/admin/app/types/inbox.ts
+++ b/frontend/apps/admin/app/types/inbox.ts
@@ -12,13 +12,13 @@ export type SessionStatus = 0 | 1 | 2 // OPEN=0, CLOSED=1, WAITING=2
* 对应后端 SessionVO
*/
export interface ChatSession {
- id: number
- userId: number
+ id: string
+ userId: string
userName: string | null
userAvatar: string | null
status: SessionStatus
contextType: string | null
- contextId: number | null
+ contextId: string | null
contextTitle: string | null
lastMessage: string | null
unreadCount: number
@@ -33,24 +33,24 @@ export interface ChatSession {
*/
export interface InboxChatMessage {
id: string
- sessionId: number
+ sessionId: string
/** 'user' = 访客来信,'assistant' = 客服回复 */
role: 'user' | 'assistant'
parts: Array<{ type: 'text'; text: string }>
/** 后端 createdTime 格式化后的显示时间 */
time: string
- senderId: number | null
+ senderId: string | null
}
/** 后端会话原始结构,对应 SessionVO */
export interface BackendSession {
- id: number
- userId: number
+ id: string
+ userId: string
userName: string | null
userAvatar: string | null
status: number
contextType: string | null
- contextId: number | null
+ contextId: string | null
contextTitle: string | null
lastMessage: string | null
unreadCount: number
@@ -59,10 +59,10 @@ export interface BackendSession {
/** 后端消息原始结构,对应 MessageVO */
export interface BackendMessage {
- id: number
- sessionId: number
+ id: string
+ sessionId: string
role: string
- senderId: number | null
+ senderId: string | null
content: string
createdTime: string
}
diff --git a/frontend/apps/admin/app/types/media-picker.ts b/frontend/apps/admin/app/types/media-picker.ts
new file mode 100644
index 00000000..b0cb0862
--- /dev/null
+++ b/frontend/apps/admin/app/types/media-picker.ts
@@ -0,0 +1,4 @@
+export type {
+ CompatMediaPickerProvider,
+ CompatMediaPickerSelection,
+} from '../../../../layers/base/types/media-picker'
diff --git a/frontend/apps/admin/app/types/migration.ts b/frontend/apps/admin/app/types/migration.ts
new file mode 100644
index 00000000..2903498d
--- /dev/null
+++ b/frontend/apps/admin/app/types/migration.ts
@@ -0,0 +1,46 @@
+export interface AdminSelectOption {
+ label: string
+ value: string
+}
+
+export interface ProductSkuInfo {
+ id: string
+ skuCode?: string
+ name?: string
+ description?: string
+ status?: string
+ isDefault?: boolean
+ attributes?: Record
+ createdTime?: string
+}
+
+export interface ProductSkuTargetInfo {
+ id: string
+ productSkuId?: string
+ targetType?: string
+ targetId?: string
+ quantity?: number
+ validityDays?: number
+ accessLevel?: string
+ conditions?: Record
+ metadata?: Record
+ status?: number
+ createdTime?: string
+ updatedTime?: string
+}
+
+export interface ProductSkuTargetFormState {
+ targetType: string
+ targetId: string
+ quantity: number
+ validityDays: number | null
+ accessLevel: string
+ status: number
+ conditions: string
+ metadata: string
+}
+
+export const productTargetStatusOptions = [
+ { label: '启用', value: '1' },
+ { label: '禁用', value: '0' },
+] satisfies AdminSelectOption[]
diff --git a/frontend/apps/admin/app/types/resource.ts b/frontend/apps/admin/app/types/resource.ts
index 08e719a9..bcf9ac35 100644
--- a/frontend/apps/admin/app/types/resource.ts
+++ b/frontend/apps/admin/app/types/resource.ts
@@ -1,3 +1,44 @@
+export interface AdminTableColumn {
+ key: string
+ label: string
+ align?: 'left' | 'center' | 'right'
+}
+
+export interface AdminResourcePageProps {
+ panelId: string
+ title: string
+ path: string
+ columns: AdminTableColumn[]
+ searchPlaceholder?: string
+ emptyText?: string
+ errorMessage?: string
+}
+
+export interface AdminPagedTableCardProps {
+ columns: AdminTableColumn[]
+ rows: Record[]
+ loading: boolean
+ errorMessage?: string
+ total: number
+ pageNum: number
+ pageSize: number
+ totalPage?: number
+ hasNext?: boolean
+ hasPrevious?: boolean
+ keyword: string
+ showSearch?: boolean
+ searchPlaceholder?: string
+ emptyText?: string
+ showActions?: boolean
+ actionsLabel?: string
+}
+
+export interface AdminPagedTableCardEmits {
+ (event: 'update:keyword', value: string): void
+ (event: 'update:pageNum', value: number): void
+ (event: 'refresh' | 'searchEnter'): void
+}
+
export interface UsePagedAdminResourceOptions {
path: string
errorMessage: string
@@ -10,4 +51,4 @@ export interface UseAdminListResourceOptions {
errorMessage: string
buildQuery?: () => Record
transform?: (value: unknown) => T[]
-}
\ No newline at end of file
+}
diff --git a/frontend/apps/admin/app/types/wechat-menu.ts b/frontend/apps/admin/app/types/wechat-menu.ts
new file mode 100644
index 00000000..c65d8236
--- /dev/null
+++ b/frontend/apps/admin/app/types/wechat-menu.ts
@@ -0,0 +1,49 @@
+export type WeChatMenuButton = {
+ type?: string
+ name?: string
+ key?: string
+ url?: string
+ mediaId?: string
+ articleId?: string
+ appId?: string
+ pagePath?: string
+ subButtons?: WeChatMenuButton[]
+ sub_button?: WeChatMenuButton[]
+}
+
+export type WeChatMenu = {
+ buttons?: WeChatMenuButton[]
+ button?: WeChatMenuButton[]
+}
+
+export type WeChatMpConditionalMenu = {
+ buttons?: WeChatMenuButton[]
+ button?: WeChatMenuButton[]
+ menuId?: string
+ rule?: Record
+}
+
+export type WeChatMpMenu = {
+ menu?: WeChatMpConditionalMenu
+ conditionalMenu?: WeChatMpConditionalMenu[]
+}
+
+export type WeChatMenuAccount = {
+ id?: string
+ accountName?: string
+ accountType?: string
+ appId?: string
+}
+
+export type WeChatMenuTypeOption = {
+ label: string
+ value: string
+}
+
+export type WeChatMenuFieldVisibility = {
+ key: boolean
+ url: boolean
+ mediaId: boolean
+ articleId: boolean
+ miniProgram: boolean
+}
diff --git a/frontend/apps/admin/app/utils/migration.ts b/frontend/apps/admin/app/utils/migration.ts
new file mode 100644
index 00000000..580c82d6
--- /dev/null
+++ b/frontend/apps/admin/app/utils/migration.ts
@@ -0,0 +1,232 @@
+export function isRecord(value: unknown): value is Record {
+ return !!value && typeof value === 'object' && !Array.isArray(value)
+}
+
+export function toOptionalString(value: unknown): string | undefined {
+ if (typeof value === 'string' && value.trim()) {
+ return value
+ }
+
+ if (typeof value === 'number' || typeof value === 'bigint') {
+ return String(value)
+ }
+
+ return undefined
+}
+
+export function toOptionalNumber(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value
+ }
+
+ if (typeof value === 'string' && value.trim()) {
+ const parsed = Number(value)
+ if (Number.isFinite(parsed)) {
+ return parsed
+ }
+ }
+
+ return undefined
+}
+
+export function toOptionalBoolean(value: unknown): boolean | undefined {
+ if (typeof value === 'boolean') {
+ return value
+ }
+
+ if (typeof value === 'string') {
+ if (value === 'true') return true
+ if (value === 'false') return false
+ }
+
+ return undefined
+}
+
+export function toStringArray(value: unknown): string[] | undefined {
+ if (!Array.isArray(value)) {
+ return undefined
+ }
+
+ const result = value
+ .map(item => toOptionalString(item))
+ .filter((item): item is string => !!item)
+
+ return result.length ? result : undefined
+}
+
+export function toNumberArray(value: unknown): number[] | undefined {
+ if (!Array.isArray(value)) {
+ return undefined
+ }
+
+ const result = value
+ .map(item => toOptionalNumber(item))
+ .filter((item): item is number => item !== undefined)
+
+ return result.length ? result : undefined
+}
+
+export function toDateTimeLocalValue(value?: string | null): string {
+ if (!value) {
+ return ''
+ }
+
+ const normalized = value.trim().replace(' ', 'T')
+ return normalized.length >= 16 ? normalized.slice(0, 16) : normalized
+}
+
+export function fromDateTimeLocalValue(value: string): string | null {
+ const normalized = value.trim()
+ if (!normalized) {
+ return null
+ }
+
+ return `${normalized.replace('T', ' ')}:00`
+}
+
+export function toPayloadOptionalString(value: string): string | null {
+ return value.trim() ? value.trim() : null
+}
+
+export function toPayloadOptionalId(value: string): string | null {
+ const normalized = value.trim()
+ return normalized || null
+}
+
+export function formatDateTime(value?: string | null, fallback = '-'): string {
+ if (!value) {
+ return fallback
+ }
+
+ const date = new Date(value.replace(' ', 'T'))
+ if (Number.isNaN(date.getTime())) {
+ return value
+ }
+
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
+}
+
+export function formatDate(value?: string | null, fallback = '-'): string {
+ if (!value) {
+ return fallback
+ }
+
+ if (value.length >= 10) {
+ return value.slice(0, 10)
+ }
+
+ return value
+}
+
+export function formatMoney(
+ value?: string | number | null,
+ currency = 'CNY',
+ fallback = '-'
+): string {
+ if (value === null || value === undefined || value === '') {
+ return fallback
+ }
+
+ const amount = typeof value === 'number' ? value : Number(value)
+ if (!Number.isFinite(amount)) {
+ return String(value)
+ }
+
+ return new Intl.NumberFormat('zh-CN', {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(amount)
+}
+
+export function jsonTextFromValue(value?: unknown): string {
+ if (!value || typeof value !== 'object') {
+ return ''
+ }
+
+ try {
+ return JSON.stringify(value, null, 2)
+ } catch {
+ return ''
+ }
+}
+
+export function parseJsonObjectText(value: string): {
+ success: boolean
+ data: Record | null
+ error?: string
+} {
+ const normalized = value.trim()
+ if (!normalized) {
+ return {
+ success: true,
+ data: null,
+ }
+ }
+
+ try {
+ const parsed = JSON.parse(normalized)
+ if (!isRecord(parsed)) {
+ return {
+ success: false,
+ data: null,
+ error: 'JSON 必须是对象',
+ }
+ }
+
+ return {
+ success: true,
+ data: parsed,
+ }
+ } catch {
+ return {
+ success: false,
+ data: null,
+ error: 'JSON 格式不正确',
+ }
+ }
+}
+
+export function isOptionalJsonObjectString(value: string): boolean {
+ return parseJsonObjectText(value).success
+}
+
+export function parseCommaSeparatedStrings(value: string): string[] {
+ return value
+ .split(',')
+ .map(item => item.trim())
+ .filter(Boolean)
+}
+
+export function parseCommaSeparatedNumbers(value: string): number[] {
+ return parseCommaSeparatedStrings(value)
+ .map(item => Number(item))
+ .filter(item => Number.isFinite(item))
+}
+
+export function joinCommaSeparated(value?: Array | null): string {
+ if (!value?.length) {
+ return ''
+ }
+
+ return value.join(', ')
+}
+
+export function normalizeFileExtension(filename: string): string {
+ const trimmed = filename.trim()
+ if (!trimmed.includes('.')) {
+ return ''
+ }
+
+ return trimmed.slice(trimmed.lastIndexOf('.') + 1).toLowerCase()
+}
+
+export function normalizeMimeType(file: File): string {
+ return file.type || 'application/octet-stream'
+}
+
+export function normalizeFilename(file: File): string {
+ return file.name.trim() || `file-${Date.now()}`
+}
+
diff --git a/frontend/apps/site/app/components/AppFooter.vue b/frontend/apps/site/app/components/AppFooter.vue
index 3fcea092..e2d4052c 100644
--- a/frontend/apps/site/app/components/AppFooter.vue
+++ b/frontend/apps/site/app/components/AppFooter.vue
@@ -47,7 +47,7 @@ const defaultColumns = [{
}]
}]
-const { footerColumns, footerCopyright, footerLinks } = useSiteConfig()
+const { footerColumns, footerCopyrightHtml, footerLinks } = useSiteConfig()
const columns = computed(() => footerColumns.value.length ? footerColumns.value : defaultColumns)
@@ -75,7 +75,8 @@ const columns = computed(() => footerColumns.value.length ? footerColumns.value
-
{{ footerCopyright }}
+
+
(
+ const activeRes = await $fetch<{ code: number; data: { id: string; status: number } | null }>(
'/api/v1/app/im/sessions/active',
{
method: 'GET',
@@ -67,7 +67,7 @@ export function useImChat() {
}
)
if (activeRes.code === 200 && activeRes.data) {
- session.value = { id: activeRes.data.id, status: activeRes.data.status }
+ session.value = { id: String(activeRes.data.id), status: activeRes.data.status }
await loadMessages()
if (ctx.subject && messages.value.length === 0) await sendMessage(ctx.subject)
startPolling()
@@ -79,7 +79,7 @@ export function useImChat() {
if (ctx.contextType) body.contextType = ctx.contextType
if (ctx.contextId) body.contextId = ctx.contextId
- const createRes = await $fetch<{ code: number; data: { id: number; status: number } }>(
+ const createRes = await $fetch<{ code: number; data: { id: string; status: number } }>(
'/api/v1/app/im/sessions',
{
method: 'POST',
@@ -90,7 +90,7 @@ export function useImChat() {
}
)
if (createRes.code === 200 && createRes.data) {
- session.value = { id: createRes.data.id, status: createRes.data.status }
+ session.value = { id: String(createRes.data.id), status: createRes.data.status }
await loadMessages()
if (ctx.subject && messages.value.length === 0) await sendMessage(ctx.subject)
startPolling()
@@ -114,7 +114,7 @@ export function useImChat() {
try {
const res = await $fetch<{
code: number
- data: { records: Array<{ id: number; role: string; content: string; createdTime: string }> }
+ data: { records: Array<{ id: string; role: string; content: string; createdTime: string }> }
}>(
`/api/v1/app/im/sessions/${session.value.id}/messages`,
{
@@ -143,7 +143,7 @@ export function useImChat() {
async function syncSessionStatus() {
if (!session.value) return
try {
- const res = await $fetch<{ code: number; data: { id: number; status: number } }>(
+ const res = await $fetch<{ code: number; data: { id: string; status: number } }>(
`/api/v1/app/im/sessions/${session.value.id}`,
{
method: 'GET',
@@ -170,7 +170,7 @@ export function useImChat() {
try {
const res = await $fetch<{
code: number
- data: { id: number; role: string; content: string; createdTime: string }
+ data: { id: string; role: string; content: string; createdTime: string }
}>(
`/api/v1/app/im/sessions/${session.value.id}/messages`,
{
@@ -214,7 +214,11 @@ export function useImChat() {
}
)
if (res.code === 200) {
- sessions.value = res.data?.records ?? []
+ sessions.value = (res.data?.records ?? []).map((item) => ({
+ ...item,
+ id: String(item.id),
+ contextId: item.contextId == null ? null : String(item.contextId),
+ }))
}
} catch {
// 静默忽略
@@ -223,13 +227,13 @@ export function useImChat() {
}
}
- async function openHistorySession(sessionId: number): Promise {
+ async function openHistorySession(sessionId: string): Promise {
historyLoading.value = true
historyMessages.value = []
try {
const res = await $fetch<{
code: number
- data: { records: Array<{ id: number; role: string; content: string; createdTime: string }> }
+ data: { records: Array<{ id: string; role: string; content: string; createdTime: string }> }
}>(
`/api/v1/app/im/sessions/${sessionId}/messages`,
{
diff --git a/frontend/apps/site/app/composables/useSiteConfig.ts b/frontend/apps/site/app/composables/useSiteConfig.ts
index 6d188c6e..adf14a66 100644
--- a/frontend/apps/site/app/composables/useSiteConfig.ts
+++ b/frontend/apps/site/app/composables/useSiteConfig.ts
@@ -121,6 +121,7 @@ export const useSiteConfig = () => {
const footerCopyright = computed(() =>
normalizeConfigValue(values.value['footer.copyright']) ?? `${communityName.value} • © ${new Date().getFullYear()}`,
)
+ const footerCopyrightHtml = useSanitizedHtml(footerCopyright)
/** ICP 备案号(兼容旧调用别名) */
const icp = computed(() => normalizeConfigValue(values.value['footer.icp']))
@@ -158,6 +159,7 @@ export const useSiteConfig = () => {
siteFavicon,
icp,
footerCopyright,
+ footerCopyrightHtml,
footerLinks,
footerColumns,
titleTemplate
diff --git a/frontend/apps/site/app/types/im.ts b/frontend/apps/site/app/types/im.ts
index 0f0008df..e01371b1 100644
--- a/frontend/apps/site/app/types/im.ts
+++ b/frontend/apps/site/app/types/im.ts
@@ -4,7 +4,7 @@
/** 咨询会话信息(创建成功后保存到本地) */
export interface SiteSession {
- id: number
+ id: string
status: number
}
@@ -23,19 +23,19 @@ export interface SiteChatMessage {
/** 发起会话的上下文(用于标识用户在咨询哪个产品/服务) */
export interface ConsultContext {
contextType?: string
- contextId?: number
+ contextId?: string
/** 前端展示用的预设问题 */
subject?: string
}
/** 历史会话列表项 */
export interface SiteSessionItem {
- id: number
+ id: string
status: number
lastMessage: string | null
updatedTime: string
contextType: string | null
- contextId: number | null
+ contextId: string | null
}
/** 会话状态常量 */
diff --git a/frontend/layers/base/components/CompatMediaPickerModal.vue b/frontend/layers/base/components/CompatMediaPickerModal.vue
new file mode 100644
index 00000000..bd62aa4d
--- /dev/null
+++ b/frontend/layers/base/components/CompatMediaPickerModal.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 筛选
+
+
+
+
+
+
+
+
+
+ {{ emptyText }}
+
+
+
+
+
共 {{ total }} 条
+
+
+ 上一页
+
+ {{ pageNum }} / {{ totalPage || 1 }}
+
+ 下一页
+
+
+
+
+
+
+
+
+
+ 关闭
+
+
+
+
+
diff --git a/frontend/layers/base/components/CompatRichTextEditor.vue b/frontend/layers/base/components/CompatRichTextEditor.vue
new file mode 100644
index 00000000..ecc8e812
--- /dev/null
+++ b/frontend/layers/base/components/CompatRichTextEditor.vue
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/layers/base/types/media-picker.ts b/frontend/layers/base/types/media-picker.ts
new file mode 100644
index 00000000..ff85135a
--- /dev/null
+++ b/frontend/layers/base/types/media-picker.ts
@@ -0,0 +1,66 @@
+export type CompatMediaPickerColor =
+ | 'primary'
+ | 'secondary'
+ | 'success'
+ | 'info'
+ | 'warning'
+ | 'error'
+ | 'neutral'
+
+export interface CompatMediaPickerOption {
+ label: string
+ value: string
+}
+
+export interface CompatMediaPickerFilterField {
+ key: string
+ type: 'input' | 'select'
+ placeholder: string
+ className?: string
+ items?: CompatMediaPickerOption[]
+}
+
+export interface CompatMediaPickerBadge {
+ label: string
+ color?: CompatMediaPickerColor
+}
+
+export interface CompatMediaPickerSelection {
+ id: string
+ label: string
+ url?: string
+ assetType?: string
+ fileType?: string
+ fileExtension?: string
+ raw?: unknown
+}
+
+export interface CompatMediaPickerLoadContext {
+ keyword: string
+ pageNum: number
+ filters: Record
+}
+
+export interface CompatMediaPickerLoadResult {
+ records: unknown[]
+ total: number
+ totalPage: number
+ hasNext: boolean
+ hasPrevious: boolean
+}
+
+export interface CompatMediaPickerProvider {
+ filters?: CompatMediaPickerFilterField[]
+ defaultFilters?: Record
+ searchPlaceholder?: string
+ emptyText?: string
+ load: (context: CompatMediaPickerLoadContext) => Promise
+ toSelection: (record: unknown) => CompatMediaPickerSelection
+ getKey: (record: unknown) => string
+ getLabel: (record: unknown) => string
+ getPreviewUrl?: (record: unknown) => string
+ isPreviewImage?: (record: unknown) => boolean
+ getIcon?: (record: unknown) => string
+ getBadge?: (record: unknown) => CompatMediaPickerBadge | null
+ getMetaLines?: (record: unknown) => string[]
+}
diff --git a/frontend/layers/base/types/rich-text.ts b/frontend/layers/base/types/rich-text.ts
new file mode 100644
index 00000000..38f2eed1
--- /dev/null
+++ b/frontend/layers/base/types/rich-text.ts
@@ -0,0 +1,42 @@
+export type RichTextEditorContentType = 'html' | 'markdown' | 'json'
+
+export type RichTextEditorToolbarItem = Record
+
+export type RichTextEditorToolbarGroup = RichTextEditorToolbarItem[]
+
+export type RichTextEditorMentionItem = Record
+
+import type { CompatMediaPickerProvider } from './media-picker'
+
+export interface RichTextEditorCommandChain {
+ focus: () => RichTextEditorCommandChain
+ setImage: (attributes: Record) => RichTextEditorCommandChain
+ insertContent: (content: Record) => RichTextEditorCommandChain
+ run: () => void
+}
+
+export interface RichTextEditorInstance {
+ chain: () => RichTextEditorCommandChain
+}
+
+export interface CompatRichTextEditorProps {
+ placeholder?: string
+ minHeightClass?: string
+ contentType?: RichTextEditorContentType
+ toolbarItems?: RichTextEditorToolbarGroup[]
+ toolbarButtonSize?: 'xs' | 'sm' | 'md'
+ enableImageUpload?: boolean
+ uploadEndpoint?: string
+ uploadAccept?: string
+ uploadMaxSize?: number
+ uploadFileKindLabel?: string
+ resolveUploadedUrl?: boolean
+ enableAssetPicker?: boolean
+ assetPickerProvider?: CompatMediaPickerProvider | null
+ assetPickerFilterValues?: Record
+ assetPickerTitle?: string
+ assetPickerDescription?: string
+ showMentionMenu?: boolean
+ mentionMenuItems?: RichTextEditorMentionItem[]
+ mentionMenuIgnoreFilter?: boolean
+}
diff --git a/frontend/packages/auth/src/admin-store.ts b/frontend/packages/auth/src/admin-store.ts
index a6697b3b..29f6b9ba 100644
--- a/frontend/packages/auth/src/admin-store.ts
+++ b/frontend/packages/auth/src/admin-store.ts
@@ -26,7 +26,7 @@ interface AdminAuthRuntimeConfigLike {
}
export interface AdminLoginResponse {
- id?: number
+ id?: string
account?: string
token?: string
refreshToken?: string
@@ -135,7 +135,10 @@ export function createAdminAuthStore(
tokenCookie.value = payload.token
refreshTokenCookie.value = payload.refreshToken ?? null
tokenTypeCookie.value = 'Bearer'
- userCookie.value = { id: payload.id, account: payload.account }
+ userCookie.value = {
+ id: payload.id == null ? undefined : String(payload.id),
+ account: payload.account,
+ }
}
function clearTokens() {
@@ -293,4 +296,4 @@ export function createAdminAuthStore(
buildClient,
}
}
-}
\ No newline at end of file
+}
diff --git a/frontend/packages/auth/src/web-store.ts b/frontend/packages/auth/src/web-store.ts
index c1c67a3e..d5fa5865 100644
--- a/frontend/packages/auth/src/web-store.ts
+++ b/frontend/packages/auth/src/web-store.ts
@@ -26,7 +26,7 @@ interface AuthRuntimeConfigLike {
}
export interface WebLoginResponse {
- memberId?: number
+ memberId?: string
username?: string
nickname?: string
avatarUrl?: string
@@ -37,6 +37,15 @@ export interface WebLoginResponse {
refreshExpiresIn?: number
}
+export interface WebAuthSessionPatch {
+ token?: string | null
+ refreshToken?: string | null
+ tokenType?: string | null
+ expiresIn?: number | null
+ refreshExpiresIn?: number | null
+ user?: Record | null
+}
+
export interface FetchCurrentUserOptions {
noRetryOnUnauthorized?: boolean
}
@@ -131,6 +140,9 @@ export function createWebAuthStore(options: CreateWebAuthStoreOptions) {
const isAuthenticated = computed(() => Boolean(tokenCookie.value))
const accessToken = computed(() => tokenCookie.value ?? '')
+ const refreshToken = computed(() => refreshTokenCookie.value ?? '')
+ const tokenType = computed(() => tokenTypeCookie.value ?? 'Bearer')
+ const currentUser = computed(() => userCookie.value ?? null)
const authHeader = computed(() => {
if (!tokenCookie.value) return ''
return `${tokenTypeCookie.value ?? 'Bearer'} ${tokenCookie.value}`
@@ -168,13 +180,28 @@ export function createWebAuthStore(options: CreateWebAuthStoreOptions) {
tokenTypeCookie.value = payload.tokenType ?? 'Bearer'
userCookie.value = {
...(userCookie.value ?? {}),
- memberId: payload.memberId,
+ memberId: payload.memberId == null ? undefined : String(payload.memberId),
username: payload.username,
nickname: payload.nickname,
avatarUrl: payload.avatarUrl,
}
}
+ function patchSession(patch: WebAuthSessionPatch) {
+ if ('token' in patch) {
+ tokenCookie.value = patch.token ?? null
+ }
+ if ('refreshToken' in patch) {
+ refreshTokenCookie.value = patch.refreshToken ?? null
+ }
+ if ('tokenType' in patch) {
+ tokenTypeCookie.value = patch.tokenType ?? 'Bearer'
+ }
+ if ('user' in patch) {
+ userCookie.value = patch.user ?? null
+ }
+ }
+
function clearTokens() {
tokenCookie.value = null
refreshTokenCookie.value = null
@@ -293,6 +320,9 @@ export function createWebAuthStore(options: CreateWebAuthStoreOptions) {
refreshPromise,
isAuthenticated,
accessToken,
+ refreshToken,
+ tokenType,
+ currentUser,
authHeader,
restore,
persist,
@@ -303,8 +333,9 @@ export function createWebAuthStore(options: CreateWebAuthStoreOptions) {
fetchCurrentUser,
restoreSession,
setSessionUser,
+ patchSession,
logout,
buildClient,
}
}
-}
\ No newline at end of file
+}
diff --git a/frontend/packages/core-sdk/src/admin.ts b/frontend/packages/core-sdk/src/admin.ts
index bb243697..143df193 100644
--- a/frontend/packages/core-sdk/src/admin.ts
+++ b/frontend/packages/core-sdk/src/admin.ts
@@ -43,7 +43,7 @@ export async function fetchAdminGet(
export async function fetchAdminPost(
api: ApiInvoker,
path: string,
- body: Record
+ body: unknown
): Promise {
const response = await api>(path, {
method: 'POST',
@@ -59,7 +59,7 @@ export async function fetchAdminPost(
export async function fetchAdminPut(
api: ApiInvoker,
path: string,
- body: Record
+ body: unknown
): Promise {
const response = await api>(path, {
method: 'PUT',
@@ -103,7 +103,7 @@ export async function fetchAdminDelete(
export async function fetchAdminBatchDelete(
api: ApiInvoker,
path: string,
- ids: (string | number)[]
+ ids: string[]
): Promise {
const response = await api>(path, {
method: 'DELETE',
diff --git a/frontend/packages/core-sdk/src/index.ts b/frontend/packages/core-sdk/src/index.ts
index ccca52bb..0449d3a0 100644
--- a/frontend/packages/core-sdk/src/index.ts
+++ b/frontend/packages/core-sdk/src/index.ts
@@ -2,3 +2,4 @@ export * from './types'
export * from './admin'
export * from './auth'
export * from './product'
+export * from './member'
diff --git a/frontend/packages/core-sdk/src/member.ts b/frontend/packages/core-sdk/src/member.ts
new file mode 100644
index 00000000..eadaa8c6
--- /dev/null
+++ b/frontend/packages/core-sdk/src/member.ts
@@ -0,0 +1,338 @@
+import type { ApiInvoker } from './admin'
+import type {
+ GlobalResult,
+ OAuth2AuthUrlResponse,
+ OAuth2LoginResponse,
+ OAuth2QRCodeResponse,
+ OAuth2QRCodeStateResponse,
+ PageResult,
+ QRCodeLoginResponse,
+ TokenRefreshResponse,
+ WeChatSilentOpenIdResponse,
+} from './types'
+
+function assertSuccess(
+ response: GlobalResult | null | undefined,
+ fallbackMessage: string,
+) {
+ if (!response || response.code !== 200) {
+ throw new Error(response?.message || fallbackMessage)
+ }
+
+ return response.data
+}
+
+function normalizeOAuth2LoginResponse(value: OAuth2LoginResponse | null | undefined): OAuth2LoginResponse | null {
+ if (!value) {
+ return null
+ }
+
+ return {
+ ...value,
+ memberId: value.memberId == null ? null : String(value.memberId),
+ }
+}
+
+function normalizeWeChatSilentOpenIdResponse(
+ value: WeChatSilentOpenIdResponse | null | undefined,
+): WeChatSilentOpenIdResponse | null {
+ if (!value) {
+ return null
+ }
+
+ return {
+ ...value,
+ memberId: value.memberId == null ? null : String(value.memberId),
+ }
+}
+
+export async function registerAppMember(
+ api: ApiInvoker,
+ payload: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/register', {
+ method: 'POST',
+ body: payload,
+ })
+
+ const memberId = assertSuccess(response, '会员注册失败')
+ return memberId == null ? null : String(memberId)
+}
+
+export async function loginAppMember>(
+ api: ApiInvoker,
+ payload: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/login', {
+ method: 'POST',
+ body: payload,
+ })
+
+ return assertSuccess(response, '会员登录失败') ?? null
+}
+
+export async function loginAppMemberByPhone>(
+ api: ApiInvoker,
+ payload: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/login-by-phone', {
+ method: 'POST',
+ body: payload,
+ })
+
+ return assertSuccess(response, '手机号登录失败') ?? null
+}
+
+export async function refreshAppMemberToken>(
+ api: ApiInvoker,
+ payload: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/refresh-token', {
+ method: 'POST',
+ body: payload,
+ })
+
+ return assertSuccess(response, '刷新 token 失败') ?? null
+}
+
+export async function refreshAppMemberTokenByJwt(
+ api: ApiInvoker,
+ authHeader: string,
+): Promise {
+ const response = await api>(
+ '/api/v1/app/auth/refresh-token-by-jwt',
+ {
+ method: 'POST',
+ headers: {
+ Authorization: authHeader,
+ },
+ },
+ )
+
+ return assertSuccess(response, '刷新 token 失败') ?? null
+}
+
+export async function fetchAppMemberProfile>(
+ api: ApiInvoker,
+ options?: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/profile', {
+ method: 'GET',
+ ...options,
+ })
+
+ return assertSuccess(response, '获取会员信息失败') ?? null
+}
+
+export async function updateAppMemberProfile(
+ api: ApiInvoker,
+ payload: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/profile', {
+ method: 'PUT',
+ body: payload,
+ })
+
+ return Boolean(assertSuccess(response, '更新会员信息失败'))
+}
+
+export async function updateAppMemberPassword(
+ api: ApiInvoker,
+ payload: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/password', {
+ method: 'PUT',
+ body: payload,
+ })
+
+ return Boolean(assertSuccess(response, '更新密码失败'))
+}
+
+export async function updateAppMemberUsername(
+ api: ApiInvoker,
+ payload: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/username', {
+ method: 'PUT',
+ body: payload,
+ })
+
+ return Boolean(assertSuccess(response, '更新用户名失败'))
+}
+
+export async function sendAppMemberCode(
+ api: ApiInvoker,
+ payload: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/send-code', {
+ method: 'POST',
+ body: payload,
+ })
+
+ return Boolean(assertSuccess(response, '发送验证码失败'))
+}
+
+export async function verifyAppMemberCode(
+ api: ApiInvoker,
+ payload: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/verify-code', {
+ method: 'POST',
+ body: payload,
+ })
+
+ return Boolean(assertSuccess(response, '验证码校验失败'))
+}
+
+export async function resetAppMemberPassword(
+ api: ApiInvoker,
+ payload: Record,
+): Promise {
+ const response = await api>('/api/v1/app/auth/reset-password', {
+ method: 'POST',
+ body: payload,
+ })
+
+ return Boolean(assertSuccess(response, '重置密码失败'))
+}
+
+export async function fetchWechatOAuthQrCode(
+ api: ApiInvoker,
+ registrationId = 'wechat-app',
+): Promise {
+ const response = await api>(
+ '/api/v1/app/oauth2/wechat/qrcode',
+ {
+ method: 'GET',
+ query: { registrationId },
+ },
+ )
+
+ return assertSuccess(response, '获取微信二维码失败') ?? null
+}
+
+export async function fetchOAuthQrCodeState(
+ api: ApiInvoker,
+ state: string,
+): Promise {
+ const response = await api>(
+ `/api/v1/app/oauth2/qrcode/state/${state}`,
+ {
+ method: 'GET',
+ },
+ )
+
+ return assertSuccess(response, '获取二维码状态失败') ?? null
+}
+
+export async function fetchWechatMobileAuthUrl(
+ api: ApiInvoker,
+ registrationId = 'wechat-app',
+ scope = 'snsapi_userinfo',
+): Promise {
+ const response = await api>(
+ '/api/v1/app/oauth2/wechat/mobile/auth-url',
+ {
+ method: 'GET',
+ query: { registrationId, scope },
+ },
+ )
+
+ return assertSuccess(response, '获取微信移动授权地址失败') ?? null
+}
+
+export async function fetchOAuthAuthorizationUrl(
+ api: ApiInvoker,
+ registrationId: string,
+): Promise {
+ const response = await api>(
+ `/api/v1/app/oauth2/auth-url/${registrationId}`,
+ {
+ method: 'GET',
+ },
+ )
+
+ return assertSuccess(response, '获取 OAuth2 授权地址失败') ?? null
+}
+
+export async function exchangeAppOAuthState(
+ api: ApiInvoker,
+ state: string,
+): Promise {
+ const response = await api>('/api/v1/app/oauth2/callback', {
+ method: 'GET',
+ query: { state },
+ })
+
+ return normalizeOAuth2LoginResponse(assertSuccess(response, '兑换 OAuth2 登录态失败'))
+}
+
+export async function createWechatLoginQrCode(
+ api: ApiInvoker,
+ payload?: { expireSeconds?: number; appId?: string },
+): Promise {
+ const response = await api>('/api/v1/app/oauth2/mp/qrcode', {
+ method: 'POST',
+ query: payload ?? {},
+ })
+
+ return assertSuccess(response, '创建微信登录二维码失败') ?? null
+}
+
+export async function fetchWechatSilentAuthUrl(
+ api: ApiInvoker,
+ payload: { redirectUri: string; appId?: string },
+): Promise {
+ const response = await api>(
+ '/api/v1/app/oauth2/wechat/silent/auth-url',
+ {
+ method: 'GET',
+ query: payload,
+ },
+ )
+
+ return assertSuccess(response, '获取微信静默授权地址失败') ?? null
+}
+
+export async function fetchWechatSilentOpenId(
+ api: ApiInvoker,
+ code: string,
+ appId?: string,
+): Promise {
+ const response = await api>(
+ '/api/v1/app/oauth2/wechat/silent/openid',
+ {
+ method: 'GET',
+ query: {
+ code,
+ appId,
+ },
+ },
+ )
+
+ return normalizeWeChatSilentOpenIdResponse(assertSuccess(response, '获取微信 openId 失败'))
+}
+
+export async function uploadAppFile>(
+ api: ApiInvoker,
+ formData: FormData,
+): Promise {
+ const response = await api>('/api/v1/app/files', {
+ method: 'POST',
+ body: formData,
+ })
+
+ return assertSuccess(response, '上传文件失败') ?? null
+}
+
+export async function fetchFamilyList(
+ api: ApiInvoker,
+ query?: Record,
+): Promise | null> {
+ const response = await api>>('/api/v1/app/families', {
+ method: 'GET',
+ query,
+ })
+
+ return assertSuccess(response, '获取家庭列表失败') ?? null
+}
diff --git a/frontend/packages/core-sdk/src/product.ts b/frontend/packages/core-sdk/src/product.ts
index 45fb6284..c50a5df9 100644
--- a/frontend/packages/core-sdk/src/product.ts
+++ b/frontend/packages/core-sdk/src/product.ts
@@ -61,6 +61,8 @@ export interface AppProduct {
status?: number
isFeatured?: boolean
sortNo?: number
+ viewCount?: number
+ saleCount?: number
createdTime?: string
updatedTime?: string
publishedTime?: string
@@ -79,6 +81,25 @@ export interface AppProductCategory {
children?: AppProductCategory[]
}
+export interface AppProductSkuTarget {
+ id: string
+ productSkuId?: string
+ targetType?: string
+ targetId?: string
+ quantity?: number
+ validityDays?: number
+ accessLevel?: string
+ conditions?: Record
+ metadata?: Record
+ status?: number
+ createdTime?: string
+ updatedTime?: string
+}
+
+export interface AppProductDetail extends AppProduct {
+ skuTargets?: AppProductSkuTarget[]
+}
+
export function normalizeAppProduct(value: unknown): AppProduct | null {
if (!isRecord(value)) {
return null
@@ -109,6 +130,8 @@ export function normalizeAppProduct(value: unknown): AppProduct | null {
status: typeof value.status === 'number' ? value.status : undefined,
isFeatured: typeof value.isFeatured === 'boolean' ? value.isFeatured : undefined,
sortNo: typeof value.sortNo === 'number' ? value.sortNo : undefined,
+ viewCount: typeof value.viewCount === 'number' ? value.viewCount : undefined,
+ saleCount: typeof value.saleCount === 'number' ? value.saleCount : undefined,
createdTime: toOptionalString(value.createdTime),
updatedTime: toOptionalString(value.updatedTime),
publishedTime: toOptionalString(value.publishedTime),
@@ -125,6 +148,60 @@ export function normalizeAppProducts(value: unknown): AppProduct[] {
.filter((item): item is AppProduct => !!item)
}
+export function normalizeAppProductSkuTarget(value: unknown): AppProductSkuTarget | null {
+ if (!isRecord(value)) {
+ return null
+ }
+
+ const id = toOptionalString(value.id)
+ if (!id) {
+ return null
+ }
+
+ return {
+ id,
+ productSkuId: toOptionalString(value.productSkuId),
+ targetType: toOptionalString(value.targetType),
+ targetId: toOptionalString(value.targetId),
+ quantity: typeof value.quantity === 'number' ? value.quantity : undefined,
+ validityDays: typeof value.validityDays === 'number' ? value.validityDays : undefined,
+ accessLevel: toOptionalString(value.accessLevel),
+ conditions: toRecord(value.conditions),
+ metadata: toRecord(value.metadata),
+ status: typeof value.status === 'number' ? value.status : undefined,
+ createdTime: toOptionalString(value.createdTime),
+ updatedTime: toOptionalString(value.updatedTime),
+ }
+}
+
+export function normalizeAppProductSkuTargets(value: unknown): AppProductSkuTarget[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+
+ return value
+ .map(item => normalizeAppProductSkuTarget(item))
+ .filter((item): item is AppProductSkuTarget => !!item)
+}
+
+export function normalizeAppProductDetail(value: unknown): AppProductDetail | null {
+ if (!isRecord(value)) {
+ return null
+ }
+
+ const product = normalizeAppProduct(value)
+ if (!product) {
+ return null
+ }
+
+ const skuTargets = normalizeAppProductSkuTargets(value.skuTargets)
+
+ return {
+ ...product,
+ skuTargets: skuTargets.length ? skuTargets : undefined,
+ }
+}
+
export function normalizeAppProductCategory(value: unknown): AppProductCategory | null {
if (!isRecord(value)) {
return null
@@ -183,12 +260,45 @@ export async function fetchAppProductsByType(api: ApiInvoker, productType: strin
return normalizeAppProducts(assertSuccess(response, '获取产品列表失败'))
}
+export async function fetchAllAppProducts(api: ApiInvoker): Promise {
+ const productTypes = await fetchAppProductTypes(api)
+ const typeKeys = Object.keys(productTypes)
+ if (!typeKeys.length) {
+ return []
+ }
+
+ const productLists = await Promise.all(typeKeys.map(productType => fetchAppProductsByType(api, productType)))
+ const deduped = new Map()
+ for (const product of productLists.flat()) {
+ deduped.set(product.id, product)
+ }
+
+ return [...deduped.values()].sort((left, right) => {
+ const leftSort = left.sortNo ?? Number.MAX_SAFE_INTEGER
+ const rightSort = right.sortNo ?? Number.MAX_SAFE_INTEGER
+ if (leftSort !== rightSort) {
+ return leftSort - rightSort
+ }
+
+ return left.title.localeCompare(right.title, 'zh-CN')
+ })
+}
+
export async function fetchAppProductById(api: ApiInvoker, id: string): Promise {
+ const detail = await fetchAppProductDetail(api, id)
+ if (!detail) {
+ return null
+ }
+
+ return detail
+}
+
+export async function fetchAppProductDetail(api: ApiInvoker, id: string): Promise {
const response = await api>(`/api/v1/products/${id}`, {
method: 'GET',
})
- return normalizeAppProduct(assertSuccess(response, '获取产品详情失败'))
+ return normalizeAppProductDetail(assertSuccess(response, '获取产品详情失败'))
}
export async function fetchAppProductCategories(api: ApiInvoker): Promise {
diff --git a/frontend/packages/core-sdk/src/types.ts b/frontend/packages/core-sdk/src/types.ts
index e7fb3b54..0efc0418 100644
--- a/frontend/packages/core-sdk/src/types.ts
+++ b/frontend/packages/core-sdk/src/types.ts
@@ -18,4 +18,71 @@ export interface PageResult {
offset?: number
}
+export interface OAuth2AuthUrlResponse {
+ authorizationUrl?: string
+ state?: string
+ appId?: string | null
+ clientId?: string | null
+ redirectUri?: string | null
+ scope?: string | null
+ authType?: string | null
+}
+
+export interface OAuth2QRCodeResponse {
+ authorizationUri?: string
+ qrCodeUrl?: string
+ state?: string
+ appId?: string | null
+ clientId?: string | null
+ redirectUri?: string | null
+ scope?: string | null
+}
+
+export interface OAuth2QRCodeStateResponse {
+ state?: string
+ qrcodeState?: number
+ status?: number
+}
+
+export interface OAuth2LoginResponse {
+ memberId?: string | null
+ token?: string
+ refreshToken?: string
+ tokenType?: string
+ expiresIn?: number
+ refreshExpiresIn?: number
+ nickname?: string | null
+ avatarUrl?: string | null
+ username?: string | null
+ openId?: string | null
+ unionId?: string | null
+ thirdPartyNickname?: string | null
+ thirdPartyAvatarUrl?: string | null
+ isNewUser?: boolean | null
+ isBound?: boolean | null
+}
+
+export interface QRCodeLoginResponse {
+ sceneStr?: string
+ qrCodeUrl?: string
+ ticket?: string
+ expireSeconds?: number
+ showQrCodeUrl?: string
+}
+
+export interface TokenRefreshResponse {
+ token?: string
+ refreshToken?: string
+ tokenType?: string
+ expiresIn?: number
+ refreshExpiresIn?: number
+}
+
+export interface WeChatSilentOpenIdResponse {
+ openId?: string
+ isBound?: boolean
+ bound?: boolean
+ memberId?: string | null
+}
+
diff --git a/frontend/templates/standalone/app/components/AppFooter.vue b/frontend/templates/standalone/app/components/AppFooter.vue
index 3fcea092..e2d4052c 100644
--- a/frontend/templates/standalone/app/components/AppFooter.vue
+++ b/frontend/templates/standalone/app/components/AppFooter.vue
@@ -47,7 +47,7 @@ const defaultColumns = [{
}]
}]
-const { footerColumns, footerCopyright, footerLinks } = useSiteConfig()
+const { footerColumns, footerCopyrightHtml, footerLinks } = useSiteConfig()
const columns = computed(() => footerColumns.value.length ? footerColumns.value : defaultColumns)
@@ -75,7 +75,8 @@ const columns = computed(() => footerColumns.value.length ? footerColumns.value
-
{{ footerCopyright }}
+
+
{
const footerCopyright = computed(() =>
normalizeConfigValue(values.value['footer.copyright']) ?? `${communityName.value} • © ${new Date().getFullYear()}`
)
+ const footerCopyrightHtml = useSanitizedHtml(footerCopyright)
const icp = computed(() => normalizeConfigValue(values.value['footer.icp']))
const footerLinks = computed(() => {
const rawLinks: Array = [
@@ -137,6 +138,7 @@ export const useSiteConfig = () => {
siteFavicon,
icp,
footerCopyright,
+ footerCopyrightHtml,
footerLinks,
footerColumns,
titleTemplate
diff --git a/get-next-flyway-version.ps1 b/get-next-flyway-version.ps1
index a78f8a27..de56a3dd 100644
--- a/get-next-flyway-version.ps1
+++ b/get-next-flyway-version.ps1
@@ -12,7 +12,9 @@ $resolvedRoot = (Resolve-Path -Path $RootPath).Path
$migrationFiles = Get-ChildItem -Path $resolvedRoot -Recurse -File -Filter "V*.sql" |
Where-Object {
$_.FullName -like "*\src\main\resources\db\migration\*" -and
- $_.FullName -notlike "*\old-code\*"
+ $_.FullName -notlike "*\old-code\*" -and
+ $_.FullName -notlike "*\old-codes\*" -and
+ $_.FullName -notlike "*\.codex-worktrees\*"
}
$scripts = foreach ($file in $migrationFiles) {
@@ -102,4 +104,4 @@ if ($FailOnDuplicates) {
exit 0
}
-Write-Host "创建新 Flyway SQL 前,请使用上面的 V$nextVersion 作为候选版本,并再次确认没有并发新增。" -ForegroundColor Gray
\ No newline at end of file
+Write-Host "创建新 Flyway SQL 前,请使用上面的 V$nextVersion 作为候选版本,并再次确认没有并发新增。" -ForegroundColor Gray
diff --git a/mortise-app/src/main/java/com/rymcu/mortise/MortiseApplication.java b/mortise-app/src/main/java/com/rymcu/mortise/MortiseApplication.java
index 848cb301..98ff921f 100644
--- a/mortise-app/src/main/java/com/rymcu/mortise/MortiseApplication.java
+++ b/mortise-app/src/main/java/com/rymcu/mortise/MortiseApplication.java
@@ -5,6 +5,7 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Mortise 主应用程序
@@ -27,6 +28,7 @@
* @author ronger
*/
@EnableAsync
+@EnableScheduling
@EnableFileStorage
@SpringBootApplication
@MapperScan({"com.rymcu.mortise.**.mapper"})
diff --git a/mortise-common/src/main/java/com/rymcu/mortise/common/enumerate/FormFieldType.java b/mortise-common/src/main/java/com/rymcu/mortise/common/enumerate/FormFieldType.java
index 00b0e939..5a29c204 100644
--- a/mortise-common/src/main/java/com/rymcu/mortise/common/enumerate/FormFieldType.java
+++ b/mortise-common/src/main/java/com/rymcu/mortise/common/enumerate/FormFieldType.java
@@ -15,6 +15,8 @@
* {@link #EMAIL} → {@code }
* {@link #SELECT} → {@code },字段定义中附带 options 列表
* {@link #TEXTAREA} → {@code }
+ * {@link #RICH_TEXT} → 富文本编辑器(结构可由前端自行决定,默认输出 HTML)
+ * {@link #HTML} → HTML 富文本编辑器,适用于需要直接渲染 HTML 的配置项
*
*
* @author ronger
@@ -42,6 +44,12 @@ public enum FormFieldType {
/** 多行文本输入框 */
TEXTAREA,
+ /** 富文本编辑器,适用于长说明、内容片段等需要格式化编辑的场景 */
+ RICH_TEXT,
+
+ /** HTML 富文本编辑器,适用于需要在前台直接按 HTML 渲染的配置项 */
+ HTML,
+
/**
* 图片上传控件,接口返回已存储的文件相对路径;
* 前端渲染为缩略图预览 + 上传按钮,上传成功后将文件 URL 写回 value
diff --git a/mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/controller/ProductSkuTargetAdminController.java b/mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/controller/ProductSkuTargetAdminController.java
new file mode 100644
index 00000000..4269c41d
--- /dev/null
+++ b/mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/controller/ProductSkuTargetAdminController.java
@@ -0,0 +1,84 @@
+package com.rymcu.mortise.product.admin.controller;
+
+import com.rymcu.mortise.core.result.GlobalResult;
+import com.rymcu.mortise.log.annotation.ApiLog;
+import com.rymcu.mortise.log.annotation.OperationLog;
+import com.rymcu.mortise.product.admin.facade.ProductSkuTargetAdminFacade;
+import com.rymcu.mortise.product.entity.ProductSkuTarget;
+import com.rymcu.mortise.web.annotation.AdminController;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@Tag(name = "SKU目标映射管理", description = "产品 SKU 与业务目标映射的后台管理接口")
+@AdminController
+@RequestMapping("/products/{productId}/skus/{skuId}/targets")
+@RequiredArgsConstructor
+public class ProductSkuTargetAdminController {
+
+ private final ProductSkuTargetAdminFacade productSkuTargetAdminFacade;
+
+ @GetMapping
+ @ApiLog("查询SKU目标映射列表")
+ @Operation(summary = "查询指定 SKU 的目标映射列表")
+ @PreAuthorize("hasAuthority('product:sku-target:list')")
+ public GlobalResult> listTargets(
+ @Parameter(description = "产品ID") @PathVariable Long productId,
+ @Parameter(description = "SKU ID") @PathVariable Long skuId) {
+ return GlobalResult.success(productSkuTargetAdminFacade.listTargets(productId, skuId));
+ }
+
+ @GetMapping("/{targetId}")
+ @ApiLog("查询SKU目标映射详情")
+ @Operation(summary = "查询 SKU 目标映射详情")
+ @PreAuthorize("hasAuthority('product:sku-target:query')")
+ public GlobalResult getTarget(
+ @Parameter(description = "产品ID") @PathVariable Long productId,
+ @Parameter(description = "SKU ID") @PathVariable Long skuId,
+ @Parameter(description = "目标映射ID") @PathVariable Long targetId) {
+ return GlobalResult.success(productSkuTargetAdminFacade.getTarget(productId, skuId, targetId));
+ }
+
+ @PostMapping
+ @ApiLog("创建SKU目标映射")
+ @OperationLog(module = "SKU目标映射管理", operation = "创建SKU目标映射", recordParams = true, recordResult = true)
+ @Operation(summary = "为指定 SKU 创建目标映射")
+ @PreAuthorize("hasAuthority('product:sku-target:add')")
+ public GlobalResult createTarget(
+ @Parameter(description = "产品ID") @PathVariable Long productId,
+ @Parameter(description = "SKU ID") @PathVariable Long skuId,
+ @Valid @RequestBody ProductSkuTarget target) {
+ return GlobalResult.success(productSkuTargetAdminFacade.createTarget(productId, skuId, target));
+ }
+
+ @PutMapping("/{targetId}")
+ @ApiLog("更新SKU目标映射")
+ @OperationLog(module = "SKU目标映射管理", operation = "更新SKU目标映射", recordParams = true)
+ @Operation(summary = "更新 SKU 目标映射")
+ @PreAuthorize("hasAuthority('product:sku-target:edit')")
+ public GlobalResult updateTarget(
+ @Parameter(description = "产品ID") @PathVariable Long productId,
+ @Parameter(description = "SKU ID") @PathVariable Long skuId,
+ @Parameter(description = "目标映射ID") @PathVariable Long targetId,
+ @Valid @RequestBody ProductSkuTarget target) {
+ return GlobalResult.success(productSkuTargetAdminFacade.updateTarget(productId, skuId, targetId, target));
+ }
+
+ @DeleteMapping("/{targetId}")
+ @ApiLog("删除SKU目标映射")
+ @OperationLog(module = "SKU目标映射管理", operation = "删除SKU目标映射")
+ @Operation(summary = "删除 SKU 目标映射")
+ @PreAuthorize("hasAuthority('product:sku-target:delete')")
+ public GlobalResult deleteTarget(
+ @Parameter(description = "产品ID") @PathVariable Long productId,
+ @Parameter(description = "SKU ID") @PathVariable Long skuId,
+ @Parameter(description = "目标映射ID") @PathVariable Long targetId) {
+ return GlobalResult.success(productSkuTargetAdminFacade.deleteTarget(productId, skuId, targetId));
+ }
+}
diff --git a/mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/facade/ProductSkuTargetAdminFacade.java b/mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/facade/ProductSkuTargetAdminFacade.java
new file mode 100644
index 00000000..3667b5af
--- /dev/null
+++ b/mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/facade/ProductSkuTargetAdminFacade.java
@@ -0,0 +1,18 @@
+package com.rymcu.mortise.product.admin.facade;
+
+import com.rymcu.mortise.product.entity.ProductSkuTarget;
+
+import java.util.List;
+
+public interface ProductSkuTargetAdminFacade {
+
+ List listTargets(Long productId, Long skuId);
+
+ ProductSkuTarget getTarget(Long productId, Long skuId, Long targetId);
+
+ ProductSkuTarget createTarget(Long productId, Long skuId, ProductSkuTarget target);
+
+ boolean updateTarget(Long productId, Long skuId, Long targetId, ProductSkuTarget target);
+
+ boolean deleteTarget(Long productId, Long skuId, Long targetId);
+}
diff --git a/mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/facade/impl/ProductSkuTargetAdminFacadeImpl.java b/mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/facade/impl/ProductSkuTargetAdminFacadeImpl.java
new file mode 100644
index 00000000..14ee045e
--- /dev/null
+++ b/mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/facade/impl/ProductSkuTargetAdminFacadeImpl.java
@@ -0,0 +1,75 @@
+package com.rymcu.mortise.product.admin.facade.impl;
+
+import com.rymcu.mortise.common.exception.BusinessException;
+import com.rymcu.mortise.product.admin.facade.ProductSkuTargetAdminFacade;
+import com.rymcu.mortise.product.entity.ProductSku;
+import com.rymcu.mortise.product.entity.ProductSkuTarget;
+import com.rymcu.mortise.product.service.command.ProductSkuTargetCommandService;
+import com.rymcu.mortise.product.service.query.ProductSkuQueryService;
+import com.rymcu.mortise.product.service.query.ProductSkuTargetQueryService;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+public class ProductSkuTargetAdminFacadeImpl implements ProductSkuTargetAdminFacade {
+
+ private final ProductSkuQueryService productSkuQueryService;
+ private final ProductSkuTargetQueryService productSkuTargetQueryService;
+ private final ProductSkuTargetCommandService productSkuTargetCommandService;
+
+ public ProductSkuTargetAdminFacadeImpl(
+ ProductSkuQueryService productSkuQueryService,
+ ProductSkuTargetQueryService productSkuTargetQueryService,
+ ProductSkuTargetCommandService productSkuTargetCommandService
+ ) {
+ this.productSkuQueryService = productSkuQueryService;
+ this.productSkuTargetQueryService = productSkuTargetQueryService;
+ this.productSkuTargetCommandService = productSkuTargetCommandService;
+ }
+
+ @Override
+ public List listTargets(Long productId, Long skuId) {
+ validateSkuOwnership(productId, skuId);
+ return productSkuTargetQueryService.findByProductSkuId(skuId);
+ }
+
+ @Override
+ public ProductSkuTarget getTarget(Long productId, Long skuId, Long targetId) {
+ validateSkuOwnership(productId, skuId);
+ ProductSkuTarget target = productSkuTargetQueryService.getById(targetId);
+ if (target == null || !skuId.equals(target.getProductSkuId())) {
+ throw new BusinessException("SKU 目标映射不存在");
+ }
+ return target;
+ }
+
+ @Override
+ public ProductSkuTarget createTarget(Long productId, Long skuId, ProductSkuTarget target) {
+ validateSkuOwnership(productId, skuId);
+ target.setProductSkuId(skuId);
+ productSkuTargetCommandService.createTarget(target);
+ return target;
+ }
+
+ @Override
+ public boolean updateTarget(Long productId, Long skuId, Long targetId, ProductSkuTarget target) {
+ getTarget(productId, skuId, targetId);
+ target.setId(targetId);
+ target.setProductSkuId(skuId);
+ return productSkuTargetCommandService.updateTarget(target);
+ }
+
+ @Override
+ public boolean deleteTarget(Long productId, Long skuId, Long targetId) {
+ getTarget(productId, skuId, targetId);
+ return productSkuTargetCommandService.deleteTarget(targetId);
+ }
+
+ private void validateSkuOwnership(Long productId, Long skuId) {
+ ProductSku sku = productSkuQueryService.getById(skuId);
+ if (sku == null || !productId.equals(sku.getProductId())) {
+ throw new BusinessException("SKU 不存在或不属于当前产品");
+ }
+ }
+}
diff --git a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/dto/ApiProductModels.java b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/dto/ApiProductModels.java
index dae98c80..9fd397fc 100644
--- a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/dto/ApiProductModels.java
+++ b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/dto/ApiProductModels.java
@@ -53,5 +53,6 @@ public static class SkuTargetVO {
private Integer quantity;
private Integer validityDays;
private String accessLevel;
+ private Map metadata;
}
}
diff --git a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/impl/ProductCatalogApiFacadeImpl.java b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/impl/ProductCatalogApiFacadeImpl.java
index a9644d9a..c89f3dbb 100644
--- a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/impl/ProductCatalogApiFacadeImpl.java
+++ b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/impl/ProductCatalogApiFacadeImpl.java
@@ -9,10 +9,13 @@
import com.rymcu.mortise.product.service.query.ProductQueryService;
import com.rymcu.mortise.product.service.query.ProductSkuQueryService;
import com.rymcu.mortise.product.service.query.ProductSkuTargetQueryService;
+import com.rymcu.mortise.product.spi.ProductSkuMetadataProvider;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Component;
import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -26,15 +29,18 @@ public class ProductCatalogApiFacadeImpl implements ProductCatalogApiFacade {
private final ProductQueryService productQueryService;
private final ProductSkuQueryService productSkuQueryService;
private final ProductSkuTargetQueryService productSkuTargetQueryService;
+ private final List skuMetadataProviders;
public ProductCatalogApiFacadeImpl(
ProductQueryService productQueryService,
ProductSkuQueryService productSkuQueryService,
- ProductSkuTargetQueryService productSkuTargetQueryService
+ ProductSkuTargetQueryService productSkuTargetQueryService,
+ List skuMetadataProviders
) {
this.productQueryService = productQueryService;
this.productSkuQueryService = productSkuQueryService;
this.productSkuTargetQueryService = productSkuTargetQueryService;
+ this.skuMetadataProviders = skuMetadataProviders;
}
@Override
@@ -67,11 +73,13 @@ public ProductDetailVO getProductDetail(Long id) {
return detailVO;
}
+ Map> skuMetadata = collectSkuMetadata(skuIds);
detailVO.setSkuTargets(productSkuTargetQueryService.findByProductSkuIds(skuIds).stream()
.filter(target -> ENABLED_TARGET_STATUS.equals(target.getStatus()))
.map(target -> {
SkuTargetVO targetVO = new SkuTargetVO();
BeanUtils.copyProperties(target, targetVO);
+ targetVO.setMetadata(skuMetadata.getOrDefault(target.getProductSkuId(), Collections.emptyMap()));
return targetVO;
})
.collect(Collectors.toList()));
@@ -82,4 +90,19 @@ public ProductDetailVO getProductDetail(Long id) {
public Map listProductTypes() {
return productQueryService.getAllProductTypes();
}
+
+ private Map> collectSkuMetadata(List skuIds) {
+ if (skuIds.isEmpty() || skuMetadataProviders.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ Map> result = new LinkedHashMap<>();
+ skuMetadataProviders.stream()
+ .sorted(Comparator.comparingInt(ProductSkuMetadataProvider::getOrder))
+ .forEach(provider -> provider.getSkuMetadata(skuIds).forEach((skuId, metadata) -> {
+ Map merged = new LinkedHashMap<>(result.getOrDefault(skuId, Collections.emptyMap()));
+ merged.putAll(metadata);
+ result.put(skuId, merged);
+ }));
+ return result;
+ }
}
diff --git a/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/command/ProductSkuTargetCommandService.java b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/command/ProductSkuTargetCommandService.java
new file mode 100644
index 00000000..c28afc3c
--- /dev/null
+++ b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/command/ProductSkuTargetCommandService.java
@@ -0,0 +1,12 @@
+package com.rymcu.mortise.product.service.command;
+
+import com.rymcu.mortise.product.entity.ProductSkuTarget;
+
+public interface ProductSkuTargetCommandService {
+
+ boolean createTarget(ProductSkuTarget target);
+
+ boolean updateTarget(ProductSkuTarget target);
+
+ boolean deleteTarget(Long targetId);
+}
diff --git a/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/command/impl/ProductSkuTargetCommandServiceImpl.java b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/command/impl/ProductSkuTargetCommandServiceImpl.java
new file mode 100644
index 00000000..27744cb0
--- /dev/null
+++ b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/command/impl/ProductSkuTargetCommandServiceImpl.java
@@ -0,0 +1,31 @@
+package com.rymcu.mortise.product.service.command.impl;
+
+import com.rymcu.mortise.product.entity.ProductSkuTarget;
+import com.rymcu.mortise.product.service.ProductSkuTargetService;
+import com.rymcu.mortise.product.service.command.ProductSkuTargetCommandService;
+import org.springframework.stereotype.Service;
+
+@Service
+public class ProductSkuTargetCommandServiceImpl implements ProductSkuTargetCommandService {
+
+ private final ProductSkuTargetService productSkuTargetService;
+
+ public ProductSkuTargetCommandServiceImpl(ProductSkuTargetService productSkuTargetService) {
+ this.productSkuTargetService = productSkuTargetService;
+ }
+
+ @Override
+ public boolean createTarget(ProductSkuTarget target) {
+ return productSkuTargetService.save(target);
+ }
+
+ @Override
+ public boolean updateTarget(ProductSkuTarget target) {
+ return productSkuTargetService.updateById(target);
+ }
+
+ @Override
+ public boolean deleteTarget(Long targetId) {
+ return productSkuTargetService.removeById(targetId);
+ }
+}
diff --git a/mortise-product/mortise-product-domain/src/main/java/com/rymcu/mortise/product/spi/ProductSkuMetadataProvider.java b/mortise-product/mortise-product-domain/src/main/java/com/rymcu/mortise/product/spi/ProductSkuMetadataProvider.java
new file mode 100644
index 00000000..47881c26
--- /dev/null
+++ b/mortise-product/mortise-product-domain/src/main/java/com/rymcu/mortise/product/spi/ProductSkuMetadataProvider.java
@@ -0,0 +1,18 @@
+package com.rymcu.mortise.product.spi;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 产品 SKU 扩展元数据提供者。
+ *
+ * 业务模块可通过 SPI 为公开商品详情补充定价、库存等领域属性,避免产品模块反向依赖业务模块。
+ */
+public interface ProductSkuMetadataProvider {
+
+ Map> getSkuMetadata(List productSkuIds);
+
+ default int getOrder() {
+ return 100;
+ }
+}
diff --git a/mortise-product/mortise-product-infra/src/main/resources/db/migration/V438__Grant_Product_Sku_Target_Permissions.sql b/mortise-product/mortise-product-infra/src/main/resources/db/migration/V438__Grant_Product_Sku_Target_Permissions.sql
new file mode 100644
index 00000000..68dfd41d
--- /dev/null
+++ b/mortise-product/mortise-product-infra/src/main/resources/db/migration/V438__Grant_Product_Sku_Target_Permissions.sql
@@ -0,0 +1,54 @@
+-- 为 SKU 目标映射管理补齐权限树与系统管理员授权
+
+INSERT INTO mortise.mortise_menu (
+ id, label, permission, icon, href, status, del_flag, menu_type, sort_no, parent_id, created_time, updated_time
+)
+SELECT 10100000000000306, '查询 SKU 目标映射', 'product:sku-target:list', 'i-lucide-list', '', 1, 0, 2, 6, parent.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+FROM mortise.mortise_menu parent
+WHERE parent.permission = 'product:sku'
+ AND NOT EXISTS (SELECT 1 FROM mortise.mortise_menu WHERE permission = 'product:sku-target:list');
+
+INSERT INTO mortise.mortise_menu (
+ id, label, permission, icon, href, status, del_flag, menu_type, sort_no, parent_id, created_time, updated_time
+)
+SELECT 10100000000000307, '查看 SKU 目标映射', 'product:sku-target:query', 'i-lucide-eye', '', 1, 0, 2, 7, parent.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+FROM mortise.mortise_menu parent
+WHERE parent.permission = 'product:sku'
+ AND NOT EXISTS (SELECT 1 FROM mortise.mortise_menu WHERE permission = 'product:sku-target:query');
+
+INSERT INTO mortise.mortise_menu (
+ id, label, permission, icon, href, status, del_flag, menu_type, sort_no, parent_id, created_time, updated_time
+)
+SELECT 10100000000000308, '新增 SKU 目标映射', 'product:sku-target:add', 'i-lucide-plus', '', 1, 0, 2, 8, parent.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+FROM mortise.mortise_menu parent
+WHERE parent.permission = 'product:sku'
+ AND NOT EXISTS (SELECT 1 FROM mortise.mortise_menu WHERE permission = 'product:sku-target:add');
+
+INSERT INTO mortise.mortise_menu (
+ id, label, permission, icon, href, status, del_flag, menu_type, sort_no, parent_id, created_time, updated_time
+)
+SELECT 10100000000000309, '编辑 SKU 目标映射', 'product:sku-target:edit', 'i-lucide-pencil', '', 1, 0, 2, 9, parent.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+FROM mortise.mortise_menu parent
+WHERE parent.permission = 'product:sku'
+ AND NOT EXISTS (SELECT 1 FROM mortise.mortise_menu WHERE permission = 'product:sku-target:edit');
+
+INSERT INTO mortise.mortise_menu (
+ id, label, permission, icon, href, status, del_flag, menu_type, sort_no, parent_id, created_time, updated_time
+)
+SELECT 10100000000000310, '删除 SKU 目标映射', 'product:sku-target:delete', 'i-lucide-trash-2', '', 1, 0, 2, 10, parent.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+FROM mortise.mortise_menu parent
+WHERE parent.permission = 'product:sku'
+ AND NOT EXISTS (SELECT 1 FROM mortise.mortise_menu WHERE permission = 'product:sku-target:delete');
+
+INSERT INTO mortise.mortise_role_menu (id_mortise_role, id_mortise_menu)
+SELECT role.id, menu.id
+FROM mortise.mortise_role role
+CROSS JOIN mortise.mortise_menu menu
+WHERE role.permission = 'ADMIN'
+ AND menu.permission LIKE 'product:sku-target:%'
+ AND NOT EXISTS (
+ SELECT 1
+ FROM mortise.mortise_role_menu rm
+ WHERE rm.id_mortise_role = role.id
+ AND rm.id_mortise_menu = menu.id
+ );
diff --git a/mortise-product/mortise-product-infra/src/main/resources/db/migration/V79__Bootstrap_Product_Table_For_Community_Fk.sql b/mortise-product/mortise-product-infra/src/main/resources/db/migration/V79__Bootstrap_Product_Table_For_Community_Fk.sql
new file mode 100644
index 00000000..88f3b290
--- /dev/null
+++ b/mortise-product/mortise-product-infra/src/main/resources/db/migration/V79__Bootstrap_Product_Table_For_Community_Fk.sql
@@ -0,0 +1,30 @@
+-- 为全新库初始化提供兼容引导:
+-- community 的 V80 会先引用 mortise_product,产品完整建表位于 V100。
+-- 这里先把被外键依赖的产品主表提前建出来,后续 V100 继续补齐分类、SKU 与索引。
+
+CREATE TABLE IF NOT EXISTS mortise.mortise_product
+(
+ id BIGINT PRIMARY KEY,
+ title VARCHAR(200) NOT NULL,
+ subtitle VARCHAR(500),
+ description TEXT,
+ short_description VARCHAR(1000),
+ cover_image_url VARCHAR(500),
+ gallery_images TEXT[],
+ product_type VARCHAR(20) NOT NULL,
+ category_id BIGINT,
+ tags VARCHAR(100)[],
+ features JSONB,
+ specifications JSONB,
+ seo_title VARCHAR(200),
+ seo_description VARCHAR(500),
+ seo_keywords VARCHAR(500),
+ status INTEGER DEFAULT 0,
+ is_featured BOOLEAN DEFAULT FALSE,
+ sort_no INTEGER DEFAULT 0,
+ created_by BIGINT,
+ created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ published_time TIMESTAMP,
+ del_flag INTEGER DEFAULT 0
+);
diff --git a/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/AuthServiceImpl.java b/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/AuthServiceImpl.java
index 2532d933..d57576b0 100644
--- a/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/AuthServiceImpl.java
+++ b/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/AuthServiceImpl.java
@@ -4,6 +4,7 @@
import com.rymcu.mortise.auth.service.TokenManager;
import com.rymcu.mortise.auth.spi.StandardOAuth2UserInfo;
import com.rymcu.mortise.auth.util.OAuth2ProviderUtils;
+import com.rymcu.mortise.common.enumerate.Status;
import com.rymcu.mortise.common.exception.BusinessException;
import com.rymcu.mortise.common.exception.CaptchaException;
import com.rymcu.mortise.common.exception.ServiceException;
@@ -102,6 +103,7 @@ public Boolean register(String email, String nickname, String password, String c
user.setEmail(email);
user.setPassword(passwordEncoder.encode(password));
user.setAvatar(DEFAULT_AVATAR);
+ user.setStatus(Status.ENABLED.getCode());
boolean result = userRepository.save(user);
if (result) {
// 注册成功后执行相关初始化事件
@@ -326,6 +328,7 @@ private User createNewUserFromOAuth2(StandardOAuth2UserInfo userInfo) {
newUser.setAccount(userCommandService.nextAccount());
newUser.setEmail(userInfo.getEmail());
newUser.setPhone(userInfo.getPhone());
+ newUser.setStatus(Status.ENABLED.getCode());
String avatar = userInfo.getAvatar();
newUser.setAvatar(StringUtils.isNotBlank(avatar) ? avatar : DEFAULT_AVATAR);
diff --git a/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/SystemInitServiceImpl.java b/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/SystemInitServiceImpl.java
index 6ae932de..c65abb2b 100644
--- a/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/SystemInitServiceImpl.java
+++ b/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/SystemInitServiceImpl.java
@@ -1,5 +1,6 @@
package com.rymcu.mortise.system.service.impl;
+import com.rymcu.mortise.common.enumerate.Status;
import com.rymcu.mortise.system.constant.SystemAuthConstants;
import com.rymcu.mortise.system.entity.Role;
import com.rymcu.mortise.system.entity.User;
@@ -98,6 +99,7 @@ private Long initAdminUser(SystemInitInfo initInfo) {
admin.setPassword(passwordEncoder.encode(initInfo.getAdminPassword()));
admin.setNickname(initInfo.getAdminNickname());
admin.setEmail(initInfo.getAdminEmail());
+ admin.setStatus(Status.ENABLED.getCode());
admin.setCreatedTime(LocalDateTime.now());
userRepository.save(admin);
log.info("管理员用户创建完成,ID={}", admin.getId());
diff --git a/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/UserServiceImpl.java b/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/UserServiceImpl.java
index e4ddb0cb..ba5330fa 100644
--- a/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/UserServiceImpl.java
+++ b/mortise-system/mortise-system-application/src/main/java/com/rymcu/mortise/system/service/impl/UserServiceImpl.java
@@ -1,5 +1,6 @@
package com.rymcu.mortise.system.service.impl;
+import com.rymcu.mortise.common.enumerate.Status;
import com.rymcu.mortise.common.exception.BusinessException;
import com.rymcu.mortise.common.model.Avatar;
import com.rymcu.mortise.common.util.Utils;
@@ -134,6 +135,7 @@ public Long createUser(UserInfo userInfo) {
user.setPassword(passwordEncoder.encode(code));
user.setAvatar(Objects.isNull(userInfo.getAvatar()) ? DEFAULT_AVATAR : userInfo.getAvatar().getSrc());
user.setAccount(nextAccount());
+ user.setStatus(Status.ENABLED.getCode());
boolean result = userRepository.save(user);
if (result) {
systemCacheService.cacheUserCount(userRepository.count());
diff --git a/mortise-system/mortise-system-domain/src/main/java/com/rymcu/mortise/system/constant/SiteConfigSchema.java b/mortise-system/mortise-system-domain/src/main/java/com/rymcu/mortise/system/constant/SiteConfigSchema.java
index 5f851525..eae5e7c5 100644
--- a/mortise-system/mortise-system-domain/src/main/java/com/rymcu/mortise/system/constant/SiteConfigSchema.java
+++ b/mortise-system/mortise-system-domain/src/main/java/com/rymcu/mortise/system/constant/SiteConfigSchema.java
@@ -34,7 +34,7 @@ public enum SiteConfigSchema {
)),
FOOTER("footer", "页脚配置", List.of(
- new SiteConfigFieldDef("footer.copyright", "版权信息", FormFieldType.TEXT, false, "© 2025 Mortise", null, null),
+ new SiteConfigFieldDef("footer.copyright", "版权信息", FormFieldType.HTML, false, "© 2025 Mortise", null, null),
new SiteConfigFieldDef("footer.columns", "页脚栏目(JSON)", FormFieldType.TEXTAREA, false,
"[{\"label\":\"资源\",\"children\":[{\"label\":\"文档\",\"to\":\"/docs\"}]}]", null, null),
new SiteConfigFieldDef("footer.icp", "ICP 备案号", FormFieldType.TEXT, false, "如:粤ICP备XXXXXXXX号", null, null),
diff --git a/mortise-system/mortise-system-infra/src/main/resources/db/migration/V435__Fix_User_Status_Default_And_Init_Admin.sql b/mortise-system/mortise-system-infra/src/main/resources/db/migration/V435__Fix_User_Status_Default_And_Init_Admin.sql
new file mode 100644
index 00000000..6238c037
--- /dev/null
+++ b/mortise-system/mortise-system-infra/src/main/resources/db/migration/V435__Fix_User_Status_Default_And_Init_Admin.sql
@@ -0,0 +1,22 @@
+-- 修复用户状态语义、默认值与初始化管理员首登阻塞问题
+
+ALTER TABLE mortise.mortise_user
+ ALTER COLUMN status SET DEFAULT 1;
+
+COMMENT ON COLUMN mortise.mortise_user.status IS '状态: 0-禁用, 1-启用';
+
+-- 修复系统初始化流程创建但尚未登录的管理员账号:
+-- 1. 拥有 ADMIN 角色
+-- 2. 从未登录
+-- 3. 初始化流程未设置头像(avatar 为空)
+-- 4. 当前仍处于错误的禁用状态
+UPDATE mortise.mortise_user AS u
+SET status = 1
+FROM mortise.mortise_user_role AS ur
+JOIN mortise.mortise_role AS r
+ ON r.id = ur.id_mortise_role
+WHERE u.id = ur.id_mortise_user
+ AND r.permission = 'ADMIN'
+ AND COALESCE(u.status, 0) = 0
+ AND u.last_login_time IS NULL
+ AND (u.avatar IS NULL OR btrim(u.avatar) = '');
diff --git a/mortise-wechat/src/main/java/com/rymcu/mortise/wechat/controller/WeChatMenuController.java b/mortise-wechat/src/main/java/com/rymcu/mortise/wechat/controller/WeChatMenuController.java
new file mode 100644
index 00000000..0af57fc5
--- /dev/null
+++ b/mortise-wechat/src/main/java/com/rymcu/mortise/wechat/controller/WeChatMenuController.java
@@ -0,0 +1,95 @@
+package com.rymcu.mortise.wechat.controller;
+
+import com.rymcu.mortise.core.result.GlobalResult;
+import com.rymcu.mortise.log.annotation.ApiLog;
+import com.rymcu.mortise.log.annotation.OperationLog;
+import com.rymcu.mortise.web.annotation.AdminController;
+import com.rymcu.mortise.wechat.service.DynamicWeChatServiceManager;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import me.chanjar.weixin.common.bean.menu.WxMenu;
+import me.chanjar.weixin.common.error.WxErrorException;
+import me.chanjar.weixin.mp.bean.menu.WxMpMenu;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+/**
+ * 微信公众号自定义菜单管理控制器。
+ *
+ * @author ronger
+ * @since 1.0.0
+ */
+@Tag(name = "微信公众号菜单管理", description = "微信公众号自定义菜单维护接口")
+@Slf4j
+@AdminController
+@RequestMapping("/wechat/accounts/{accountId}/menu")
+@RequiredArgsConstructor
+public class WeChatMenuController {
+
+ /**
+ * 微信“菜单不存在”错误码(46003)。
+ */
+ private static final int MENU_NOT_FOUND_ERROR_CODE = 46003;
+
+ private final DynamicWeChatServiceManager dynamicWeChatServiceManager;
+
+ @Operation(summary = "获取公众号菜单", description = "读取指定微信账号当前已发布的自定义菜单")
+ @GetMapping
+ @ApiLog("获取公众号菜单")
+ @PreAuthorize("hasAuthority('wechat:account:query')")
+ public GlobalResult getMenu(@PathVariable @Parameter(description = "账号ID") Long accountId) {
+ try {
+ log.info("读取微信公众号菜单,accountId: {}", accountId);
+ WxMpMenu menu = dynamicWeChatServiceManager.getServiceByAccountId(accountId).getMenuService().menuGet();
+ return GlobalResult.success(menu);
+ } catch (WxErrorException e) {
+ if (e.getError() != null && e.getError().getErrorCode() == MENU_NOT_FOUND_ERROR_CODE) {
+ log.info("微信公众号尚未创建菜单,accountId: {}", accountId);
+ return GlobalResult.success(new WxMpMenu());
+ }
+ log.warn("读取微信公众号菜单失败,accountId: {}, error: {}", accountId, e.getMessage(), e);
+ return GlobalResult.error("读取微信公众号菜单失败: " + e.getMessage());
+ }
+ }
+
+ @Operation(summary = "发布公众号菜单", description = "发布指定微信账号的自定义菜单")
+ @PutMapping
+ @ApiLog("发布公众号菜单")
+ @OperationLog(module = "微信公众号菜单管理", operation = "发布公众号菜单", recordParams = true, recordResult = true)
+ @PreAuthorize("hasAuthority('wechat:account:edit')")
+ public GlobalResult updateMenu(@PathVariable @Parameter(description = "账号ID") Long accountId,
+ @RequestBody WxMenu menu) {
+ try {
+ log.info("发布微信公众号菜单,accountId: {}", accountId);
+ String menuId = dynamicWeChatServiceManager.getServiceByAccountId(accountId).getMenuService().menuCreate(menu);
+ return GlobalResult.success(menuId);
+ } catch (WxErrorException e) {
+ log.warn("发布微信公众号菜单失败,accountId: {}, error: {}", accountId, e.getMessage(), e);
+ return GlobalResult.error("发布微信公众号菜单失败: " + e.getMessage());
+ }
+ }
+
+ @Operation(summary = "删除公众号菜单", description = "删除指定微信账号当前已发布的自定义菜单")
+ @DeleteMapping
+ @ApiLog("删除公众号菜单")
+ @OperationLog(module = "微信公众号菜单管理", operation = "删除公众号菜单")
+ @PreAuthorize("hasAuthority('wechat:account:delete')")
+ public GlobalResult deleteMenu(@PathVariable @Parameter(description = "账号ID") Long accountId) {
+ try {
+ log.info("删除微信公众号菜单,accountId: {}", accountId);
+ dynamicWeChatServiceManager.getServiceByAccountId(accountId).getMenuService().menuDelete();
+ return GlobalResult.success(true);
+ } catch (WxErrorException e) {
+ log.warn("删除微信公众号菜单失败,accountId: {}, error: {}", accountId, e.getMessage(), e);
+ return GlobalResult.error("删除微信公众号菜单失败: " + e.getMessage());
+ }
+ }
+}