From fb204becf4df9230c7c87c8ea07731bde3982862 Mon Sep 17 00:00:00 2001 From: ronger Date: Thu, 7 May 2026 11:29:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(review):=20=E6=B8=85=E7=90=86=E5=85=AC?= =?UTF-8?q?=E5=BC=80=20PR=20=E5=95=86=E4=B8=9A=E6=A8=A1=E5=9D=97=E6=9A=B4?= =?UTF-8?q?=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/migration/member/legacy-import.sql | 277 ++++++++++ .../member/legacy-post-import-validation.sql | 75 +++ .../migration/member/legacy-status-remap.sql | 33 ++ config/migration/product/legacy-import.sql | 486 ++++++++++++++++++ .../product/legacy-post-import-validation.sql | 102 ++++ .../migration/product/legacy-status-remap.sql | 38 ++ .../app/components/admin/PagedTableCard.vue | 36 +- .../app/components/admin/ResourcePage.vue | 84 +++ .../dict-types/DictTypeDeleteModal.vue | 2 +- .../dict-types/DictTypeEditModal.vue | 2 +- .../app/components/dicts/DictDeleteModal.vue | 2 +- .../app/components/dicts/DictEditModal.vue | 2 +- .../dynamic-form/DynamicFormField.vue | 11 +- .../app/components/inbox/SessionList.vue | 2 +- .../components/members/MemberEditModal.vue | 2 +- .../app/components/menus/MenuDeleteModal.vue | 2 +- .../app/components/menus/MenuEditModal.vue | 2 +- .../admin/app/components/menus/MenuForm.vue | 10 +- .../app/components/menus/MenuTreeRow.vue | 6 +- .../Oauth2ClientDeleteModal.vue | 2 +- .../oauth2-clients/Oauth2ClientEditModal.vue | 2 +- .../ProductCategoryDeleteModal.vue | 2 +- .../ProductCategoryEditModal.vue | 2 +- .../ProductCategoryForm.vue | 2 +- .../TargetManagerSlideover.vue | 423 +++++++++++++++ .../product-skus/ProductSkuAddModal.vue | 2 +- .../product-skus/ProductSkuDeleteModal.vue | 2 +- .../products/ProductDeleteModal.vue | 2 +- .../components/products/ProductEditModal.vue | 2 +- .../app/components/products/ProductForm.vue | 50 +- .../app/components/roles/MenuTreeNode.vue | 6 +- .../app/components/roles/RoleDeleteModal.vue | 2 +- .../app/components/roles/RoleEditModal.vue | 2 +- .../app/components/roles/RoleMenuModal.vue | 18 +- .../app/components/roles/RoleUserModal.vue | 8 +- .../app/components/users/UserDeleteModal.vue | 2 +- .../app/components/users/UserEditModal.vue | 2 +- .../app/components/users/UserRoleModal.vue | 8 +- .../WeChatAccountDeleteModal.vue | 2 +- .../WeChatAccountEditModal.vue | 2 +- .../WeChatAccountMenuModal.vue | 419 +++++++++++++++ .../admin/app/composables/useAdminCrud.ts | 6 +- .../composables/useAdminFilePickerProvider.ts | 222 ++++++++ .../apps/admin/app/pages/agent/models.vue | 9 + .../apps/admin/app/pages/agent/providers.vue | 9 + frontend/apps/admin/app/pages/inbox.vue | 20 +- frontend/apps/admin/app/pages/members.vue | 2 +- .../admin/app/pages/products/[id]/skus.vue | 92 +++- frontend/apps/admin/app/pages/setup.vue | 18 +- .../admin/app/pages/systems/dictionaries.vue | 4 +- .../apps/admin/app/pages/systems/menus.vue | 16 +- .../apps/admin/app/pages/systems/roles.vue | 2 +- .../apps/admin/app/pages/systems/users.vue | 2 +- .../app/pages/systems/wechat-accounts.vue | 30 +- frontend/apps/admin/app/types/inbox.ts | 22 +- frontend/apps/admin/app/types/media-picker.ts | 4 + frontend/apps/admin/app/types/migration.ts | 46 ++ frontend/apps/admin/app/types/resource.ts | 43 +- frontend/apps/admin/app/types/wechat-menu.ts | 49 ++ frontend/apps/admin/app/utils/migration.ts | 232 +++++++++ .../apps/site/app/components/AppFooter.vue | 5 +- .../apps/site/app/composables/useImChat.ts | 24 +- .../site/app/composables/useSiteConfig.ts | 2 + frontend/apps/site/app/types/im.ts | 8 +- .../components/CompatMediaPickerModal.vue | 266 ++++++++++ .../base/components/CompatRichTextEditor.vue | 307 +++++++++++ frontend/layers/base/types/media-picker.ts | 66 +++ frontend/layers/base/types/rich-text.ts | 42 ++ frontend/packages/auth/src/admin-store.ts | 9 +- frontend/packages/auth/src/web-store.ts | 37 +- frontend/packages/core-sdk/src/admin.ts | 6 +- frontend/packages/core-sdk/src/index.ts | 1 + frontend/packages/core-sdk/src/member.ts | 338 ++++++++++++ frontend/packages/core-sdk/src/product.ts | 112 +++- frontend/packages/core-sdk/src/types.ts | 67 +++ .../standalone/app/components/AppFooter.vue | 5 +- .../app/composables/useSiteConfig.ts | 2 + get-next-flyway-version.ps1 | 6 +- .../com/rymcu/mortise/MortiseApplication.java | 2 + .../common/enumerate/FormFieldType.java | 8 + .../ProductSkuTargetAdminController.java | 84 +++ .../facade/ProductSkuTargetAdminFacade.java | 18 + .../impl/ProductSkuTargetAdminFacadeImpl.java | 75 +++ .../product/api/dto/ApiProductModels.java | 1 + .../impl/ProductCatalogApiFacadeImpl.java | 25 +- .../ProductSkuTargetCommandService.java | 12 + .../ProductSkuTargetCommandServiceImpl.java | 31 ++ .../spi/ProductSkuMetadataProvider.java | 18 + ...__Grant_Product_Sku_Target_Permissions.sql | 54 ++ ...otstrap_Product_Table_For_Community_Fk.sql | 30 ++ .../system/service/impl/AuthServiceImpl.java | 3 + .../service/impl/SystemInitServiceImpl.java | 2 + .../system/service/impl/UserServiceImpl.java | 2 + .../system/constant/SiteConfigSchema.java | 2 +- ...Fix_User_Status_Default_And_Init_Admin.sql | 22 + .../controller/WeChatMenuController.java | 95 ++++ 96 files changed, 4532 insertions(+), 219 deletions(-) create mode 100644 config/migration/member/legacy-import.sql create mode 100644 config/migration/member/legacy-post-import-validation.sql create mode 100644 config/migration/member/legacy-status-remap.sql create mode 100644 config/migration/product/legacy-import.sql create mode 100644 config/migration/product/legacy-post-import-validation.sql create mode 100644 config/migration/product/legacy-status-remap.sql create mode 100644 frontend/apps/admin/app/components/admin/ResourcePage.vue create mode 100644 frontend/apps/admin/app/components/product-sku-targets/TargetManagerSlideover.vue create mode 100644 frontend/apps/admin/app/components/wechat-accounts/WeChatAccountMenuModal.vue create mode 100644 frontend/apps/admin/app/composables/useAdminFilePickerProvider.ts create mode 100644 frontend/apps/admin/app/pages/agent/models.vue create mode 100644 frontend/apps/admin/app/pages/agent/providers.vue create mode 100644 frontend/apps/admin/app/types/media-picker.ts create mode 100644 frontend/apps/admin/app/types/migration.ts create mode 100644 frontend/apps/admin/app/types/wechat-menu.ts create mode 100644 frontend/apps/admin/app/utils/migration.ts create mode 100644 frontend/layers/base/components/CompatMediaPickerModal.vue create mode 100644 frontend/layers/base/components/CompatRichTextEditor.vue create mode 100644 frontend/layers/base/types/media-picker.ts create mode 100644 frontend/layers/base/types/rich-text.ts create mode 100644 frontend/packages/core-sdk/src/member.ts create mode 100644 mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/controller/ProductSkuTargetAdminController.java create mode 100644 mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/facade/ProductSkuTargetAdminFacade.java create mode 100644 mortise-product/mortise-product-admin/src/main/java/com/rymcu/mortise/product/admin/facade/impl/ProductSkuTargetAdminFacadeImpl.java create mode 100644 mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/command/ProductSkuTargetCommandService.java create mode 100644 mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/command/impl/ProductSkuTargetCommandServiceImpl.java create mode 100644 mortise-product/mortise-product-domain/src/main/java/com/rymcu/mortise/product/spi/ProductSkuMetadataProvider.java create mode 100644 mortise-product/mortise-product-infra/src/main/resources/db/migration/V438__Grant_Product_Sku_Target_Permissions.sql create mode 100644 mortise-product/mortise-product-infra/src/main/resources/db/migration/V79__Bootstrap_Product_Table_For_Community_Fk.sql create mode 100644 mortise-system/mortise-system-infra/src/main/resources/db/migration/V435__Fix_User_Status_Default_And_Init_Admin.sql create mode 100644 mortise-wechat/src/main/java/com/rymcu/mortise/wechat/controller/WeChatMenuController.java 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 @@ + + 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 @@ + + + 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 @@ + + + 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 @@ 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