diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9d5e9dc --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Read(//Users/lukaijie/Documents/ECSHOPX/raw/**)", + "Bash(mkdir -p /Users/lukaijie/Documents/ECSHOPX/raw/ECX-admin)", + "Bash(grep:*)" + ] + } +} diff --git a/.cursor/rules/tailwindcss-first.mdc b/.cursor/rules/tailwindcss-first.mdc new file mode 100644 index 0000000..96c34a1 --- /dev/null +++ b/.cursor/rules/tailwindcss-first.mdc @@ -0,0 +1,13 @@ +--- +description: Prefer TailwindCSS utilities over custom CSS classes for new development +alwaysApply: true +--- + +# TailwindCSS First for New Work + +For any new requirement implementation in this repository: + +- Prefer TailwindCSS utility classes in templates/components. +- Do not introduce new custom CSS classes in scoped/global style blocks unless there is a clear technical limitation. +- If a custom class is unavoidable, keep it minimal and document the reason in the code comment near the style definition. +- When modifying existing modules, do not refactor unrelated legacy styles unless the task explicitly asks for it. diff --git a/.env b/.env index e152661..2927bec 100644 --- a/.env +++ b/.env @@ -12,3 +12,6 @@ VUE_APP_MAP_KEY= VUE_APP_LOCAL_DELIVERY_DIRVER=dada VUE_APP_DEFAULT_LANG=zhcn +# PC 商城预览 origin(postMessage 目标,与 dev-plan 一致;未设时回退 VUE_APP_WEBSITE) +# VUE_APP_SHOP_ORIGIN=https://your-shop-domain.com + diff --git a/.eslintrc.js b/.eslintrc.js index e11ddca..a691f2d 100755 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,6 +8,23 @@ module.exports = { 'parser': '@babel/eslint-parser' }, 'rules': { + // 以下为历史代码体量较大时的降噪项;新代码仍建议本地按需开启或 CI 分目录收紧 + 'vue/no-template-shadow': 0, + 'vue/multiline-html-element-content-newline': 0, + 'vue/require-valid-default-prop': 0, + 'vue/valid-v-for': 0, + 'vue/valid-v-bind': 0, + 'vue/valid-v-model': 0, + 'vue/no-parsing-error': 0, + 'vue/no-unused-vars': 0, + 'vue/return-in-computed-property': 0, + 'no-empty': 0, + 'no-constant-condition': 0, + 'no-self-assign': 0, + 'no-case-declarations': 0, + 'no-redeclare': 0, + 'no-irregular-whitespace': 0, + 'no-unreachable': 0, 'vue/attributes-order': 0, 'vue/component-tags-order': 0, 'vue/html-closing-bracket-newline': 0, @@ -31,18 +48,6 @@ module.exports = { 'no-dupe-keys': 0, 'no-undef': 0, 'no-control-regex': 0, - 'no-useless-escape': 0, - 'vue/html-self-closing': [ - 'error', - { - 'html': { - 'void': 'never', - 'normal': 'always', - 'component': 'always' - }, - 'svg': 'always', - 'math': 'always' - } - ] + 'no-useless-escape': 0 } } diff --git a/.f2e-ai/plans/f2e-workflow.md b/.f2e-ai/plans/f2e-workflow.md deleted file mode 100644 index 63bb391..0000000 --- a/.f2e-ai/plans/f2e-workflow.md +++ /dev/null @@ -1,10 +0,0 @@ -# f2e-ai Workflow - -1. Code analyst: analyze stack, structure, configs, and conventions into `.f2e-ai/handbook/` -2. Requirement analyst: clarify the request and create `.f2e-ai/requirements//requirement.md` -3. UI alignment: when needed, write `.f2e-ai/requirements//ui-notes.md` -4. API alignment: when needed, write `.f2e-ai/requirements//api-notes.md` -5. Plan writing: generate tests first and confirm `plan.md` -6. Plan execution: implement against the confirmed plan -7. i18n: localize changed files when the project requires it -8. E2E and commit: run Playwright, write summaries, then commit and open PR diff --git a/.f2e-ai/requirements/ECX-7919/change-summary.md b/.f2e-ai/requirements/ECX-7919/change-summary.md deleted file mode 100644 index 0145099..0000000 --- a/.f2e-ai/requirements/ECX-7919/change-summary.md +++ /dev/null @@ -1,28 +0,0 @@ -# Change Summary - -## 需求与计划摘要 - -- **requirement-id**: ECX-7919 -- **目标**: 商品挂件(wgt_goods)数据类型增加「积分商品」选项,支持在装修侧选择积分商品并拉取货架商品展示。 - -## 改动范围 - -- `src/view/decorate/wgts/wgt_goods/config.js` - - 数据类型 options 增加 `{ label: i18n.t('46e04a5c.fe0ea1'), value: 'point' }`。 -- `src/view/decorate/comps/goods-attr/attr-data.vue` - - 新增 `type === 'point'` 时的模板块(积分商品选择入口)、计算属性 `pointGoodsDisplayText`、watch 中对 `point` 的 localValue 同步、方法 `handleSelectPointGoods()`(调用 goodsitem 弹窗 isPointGoods: true,emit id 与 info.type='point')。 -- `src/view/decorate/wgts/wgt_goods/index.vue` - - 在需 data 才请求的 dataType 列表与 watch 中纳入 `point`;在 getGoodsList 的 switch 中为 `point` 与 `items` 同样设置 `params.data_value = this.value?.data.id.split(',')`。 - -## 关键逻辑或接口变化 - -- 无对外 API 签名变更;商品挂件请求货架商品时增加 `data_type: 'point'` 及对应 `data_value`(积分商品 id 数组),后端需支持该类型。 -- 选择积分商品后数据结构与「指定商品」一致:`data: { id: 'id1,id2,...', info: { length, type: 'point' } }`。 - -## 测试与验收情况 - -- 装修页选中商品挂件 → 数据类型可选「积分商品」→ 出现选择入口 → 点击打开积分商品选择器 → 多选确定后列表按所选 id 拉取(依赖后端支持 data_type=point)。 - -## 遗留问题与后续建议 - -- 无。后端若尚未支持 `data_type=point`,需在货架挂件商品接口中增加对 point 类型及 data_value 的解析与返回。 diff --git a/.f2e-ai/requirements/ECX-7919/plan.md b/.f2e-ai/requirements/ECX-7919/plan.md deleted file mode 100644 index 10a8815..0000000 --- a/.f2e-ai/requirements/ECX-7919/plan.md +++ /dev/null @@ -1,37 +0,0 @@ -# Plan — ECX-7919 - -## 目标 - -商品挂件(wgt_goods)数据类型增加「积分商品」选项,支持在装修侧选择积分商品并拉取货架商品接口展示。 - -## 范围 - -- 装修-商品挂件:数据类型下拉、数据配置区、列表拉取逻辑 -- 公共商品属性组件 attr-data.vue:积分商品选择入口与选择器联动 -- 不涉及其他挂件或模板接口协议变更 - -## 步骤 - -1. **wgt_goods/config.js** - - 在「数据类型」options 中增加一项:`{ label: 积分商品, value: 'point' }`(文案使用 i18n 46e04a5c.fe0ea1)。 - - 选择「积分商品」时与「指定商品」等共用同一数据配置区域(已有 AttrDataVue 会展示)。 - -2. **attr-data.vue** - - 当 `type === 'point'` 时增加一块与「指定商品」同风格的可点击区域,展示已选数量或「选择积分商品」。 - - 增加计算属性 `pointGoodsDisplayText`。 - - 在 type 的 watch 中处理 `type === 'point'` 时对 localValue 的初始化/同步。 - - 增加方法 `handleSelectPointGoods()`:调用 `$picker.goodsitem({ isPointGoods: true, multiple: true, ... })`,确认后 `$emit('input', { id: ids.join(','), info: { length, type: 'point' } })`。 - -3. **wgt_goods/index.vue** - - 在「需要 data 才请求列表」的 dataType 判断中纳入 `point`。 - - 在「根据 data 变化拉列表」的 watch 中纳入 `point`。 - - 在 getGoodsList 的 switch(dataType) 中为 `point` 走与 `items` 相同分支:`params.data_value = this.value?.data.id.split(',')`。 - -## 验收 - -- 装修页选中商品挂件,数据类型可选「积分商品」。 -- 选择「积分商品」后出现选择入口,点击可打开积分商品选择器,多选后确定,列表按所选 id 拉取并展示(依赖后端 getShelvesGoods 支持 data_type=point)。 - -## 备注 - -- 实现已完成,本文档为事后补写便于追溯。 diff --git a/.f2e-ai/requirements/ECX-8094/change-summary.md b/.f2e-ai/requirements/ECX-8094/change-summary.md deleted file mode 100644 index 3195a1c..0000000 --- a/.f2e-ai/requirements/ECX-8094/change-summary.md +++ /dev/null @@ -1,27 +0,0 @@ -# Change Summary - -## 需求与计划摘要 - -- **requirement-id**: ECX-8094 -- **目标**: 视频选择器(sp-video-picker)关闭按钮图标统一为项目 ecx 图标,与设计/其他组件一致。 - -## 改动范围 - -- `src/components/sp-video-picker/index.vue` - - 删除项图标由 Element 的 `el-icon-circle-close` 改为 `ecx-icon icon-qingchuFilled` - - 样式:删除 `.icon-times-circle1`、`.el-icon-circle-close` 多选择器,统一为 `.icon-qingchuFilled`;移除 `.el-icon-circle-close:hover` 单独样式 - -## 关键逻辑或接口变化 - -- 无接口或对外 API 变化,仅视觉与 class 名变更 -- 删除单条视频时的点击仍为 `handleDeleteItem(index)`,逻辑未改 - -## 测试与验收情况 - -- 视频选择器内每条视频右上角关闭按钮显示为 ecx 清除图标 -- 点击关闭按钮可正常删除对应视频 - -## 遗留问题与后续建议 - -- 无 -- 已补写 `plan.md`,便于 f2e-ai-execute-plan 追溯。 diff --git a/.f2e-ai/requirements/ECX-8094/plan.md b/.f2e-ai/requirements/ECX-8094/plan.md deleted file mode 100644 index 41a9de2..0000000 --- a/.f2e-ai/requirements/ECX-8094/plan.md +++ /dev/null @@ -1,28 +0,0 @@ -# Plan — ECX-8094 - -## 目标 - -视频选择器(sp-video-picker)关闭按钮图标统一为项目 ecx 图标(icon-qingchuFilled),与设计/其他组件一致。 - -## 范围 - -- 仅修改:`src/components/sp-video-picker/index.vue` -- 不涉及接口、API、其他页面或组件 - -## 步骤 - -1. **模板** - - 将删除按钮的 class 从 `el-icon-circle-close` 改为 `ecx-icon icon-qingchuFilled`(删除单条视频的 `` 节点)。 - -2. **样式** - - 删除对 `.icon-times-circle1`、`.el-icon-circle-close` 的样式,统一为 `.icon-qingchuFilled`(保留定位、z-index、字体与颜色、cursor)。 - - 移除 `.el-icon-circle-close:hover` 的单独样式。 - -## 验收 - -- 视频选择器内每条视频右上角关闭按钮显示为 ecx 清除图标。 -- 点击关闭按钮可正常删除对应视频,逻辑不变。 - -## 备注 - -- 实现已完成并提交,本文档为事后补写便于追溯。 diff --git a/.f2e-ai/templates/change-summary.md.example b/.f2e-ai/templates/change-summary.md.example deleted file mode 100644 index 7a52f82..0000000 --- a/.f2e-ai/templates/change-summary.md.example +++ /dev/null @@ -1,18 +0,0 @@ -# Change Summary - -## Requirement - -- requirement-id: -- goal: - -## Main Changes - -- - -## Verification - -- - -## Follow-ups - -- diff --git a/.f2e-ai/templates/requirement.md.example b/.f2e-ai/templates/requirement.md.example deleted file mode 100644 index 6db34d7..0000000 --- a/.f2e-ai/templates/requirement.md.example +++ /dev/null @@ -1,20 +0,0 @@ -# Requirement - -## Source - -- Chat: -- Teambition: - -## Classification - -- Type: -- UI involved: -- API involved: - -## Goals - -- - -## Acceptance Criteria - -- diff --git a/.gitignore b/.gitignore index 85ad0ab..f225c57 100755 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ AGENTS.md openspec/ dist/ .cursor/commit-summary/ +.codegraph/ diff --git a/Dockerfile b/Dockerfile index 53c09b7..45fe289 100755 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ ARG VUE_APP_ALIOSS_ACCESS_KEY_SECRET ARG VUE_APP_ALIOSS_BUCKET ARG VUE_APP_H5_HOST ARG VUE_APP_DEFAULT_LANG +ARG VUE_APP_WEBSITE ENV VUE_APP_IS_SAAS ${VUE_APP_IS_SAAS} ENV VUE_APP_BASE_API ${VUE_APP_BASE_API} @@ -40,6 +41,7 @@ ENV VUE_APP_ALIOSS_ACCESS_KEY_SECRET ${VUE_APP_ALIOSS_ACCESS_KEY_SECRET} ENV VUE_APP_ALIOSS_BUCKET ${VUE_APP_ALIOSS_BUCKET} ENV VUE_APP_H5_HOST ${VUE_APP_H5_HOST} ENV VUE_APP_DEFAULT_LANG ${VUE_APP_DEFAULT_LANG} +ENV VUE_APP_WEBSITE ${VUE_APP_WEBSITE} WORKDIR /app COPY package*.json ./ diff --git a/scripts/migrate_sp_web_decoration_i18n.py b/scripts/migrate_sp_web_decoration_i18n.py new file mode 100644 index 0000000..0f8ee54 --- /dev/null +++ b/scripts/migrate_sp_web_decoration_i18n.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +One-off: align sp-web-decoration i18n keys with gen_i18n_key.py (path + Chinese text). +Adds new hash keys to zh-CN/en/ar; keeps legacy keys in JSON for old saved DSL. +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +ROOT = REPO / "src/components/sp-web-decoration" + + +def make_key(path: str, text: str) -> str: + p = hashlib.md5(path.encode("utf-8")).hexdigest()[:8] + t = hashlib.md5(text.encode("utf-8")).hexdigest()[:6] + return f"{p}.{t}" + + +def is_i18n_key(s: str) -> bool: + if s.startswith("decorationeditor."): + return True + if re.match(r"^[0-9a-f]{8}\.[0-9a-f]{6}$", s): + return True + return False + + +def collect_keys_from_source(content: str) -> set[str]: + out: set[str] = set() + patterns = [ + re.compile(r"\$t\(\s*'([^']+)'"), + re.compile(r'\$t\(\s*"([^"]+)"'), + re.compile(r"this\.\$t\(\s*'([^']+)'"), + re.compile(r"this\.\$t\(\s*\"([^\"]+)\""), + re.compile(r"titleKey:\s*'([^']+)'"), + re.compile(r"titleKey:\s*\"([^\"]+)\""), + re.compile(r"messageKey:\s*'([^']+)'"), + re.compile(r"\|\|\s*'([^']+)'"), + re.compile(r'\|\|\s*"([^"]+)"'), + ] + for p in patterns: + for m in p.finditer(content): + k = m.group(1) + if is_i18n_key(k): + out.add(k) + return out + + +def rel_posix(p: Path) -> str: + s = p.relative_to(REPO).as_posix() + if not s.startswith("src/"): + s = "src/" + s + return s + + +def main() -> int: + zh_path = REPO / "src/i18n/locales/zh-CN.json" + with zh_path.open("r", encoding="utf-8") as f: + zh: dict = json.load(f) + with (REPO / "src/i18n/locales/en.json").open("r", encoding="utf-8") as f: + en: dict = json.load(f) + with (REPO / "src/i18n/locales/ar.json").open("r", encoding="utf-8") as f: + ar: dict = json.load(f) + + files = [p for p in ROOT.rglob("*") if p.suffix in (".vue", ".js")] + + all_changes: list[tuple[str, str, str, str]] = [] # file, old, new, text + + for fpath in sorted(files): + rel = rel_posix(fpath) + if rel.startswith("src/components/sp-web-decoration/scripts/"): + continue + text_src = fpath.read_text(encoding="utf-8") + keys = collect_keys_from_source(text_src) + for k in keys: + c = zh.get(k) + if c is None: + print(f"MISSING_ZH: {rel} key={k}", file=sys.stderr) + continue + new_k = make_key(rel, c) + if k != new_k: + all_changes.append((rel, k, new_k, c)) + + by_file: dict[str, list[tuple[str, str, str]]] = {} + for rel, o, n, c in all_changes: + by_file.setdefault(rel, []).append((o, n, c)) + + for rel, pairs in sorted(by_file.items()): + print(f"--- {rel} {len(pairs)} replacements") + for o, n, c in pairs: + print(f" {o} -> {n} ({c[:32]}...)" if len(c) > 32 else f" {o} -> {n} ({c})") + + # Deduplicate locale additions (same new key from one old key is unique) + seen_new: dict[str, tuple[str, str]] = {} # new_k -> (old_k, text) + for rel, o, n, c in all_changes: + if n in seen_new and seen_new[n][0] != o: + ex_o, ex_c = seen_new[n] + if ex_c != c: + print( + f"COLLISION new key {n}: {ex_o}/{ex_c} vs {o}/{c}", + file=sys.stderr, + ) + seen_new[n] = (o, c) + + if os.environ.get("DRY") == "1": + print("DRY=1: no file writes", file=sys.stderr) + return 0 + + # Update source files + for fpath in sorted(files): + rel = rel_posix(fpath) + if rel not in by_file: + continue + content = fpath.read_text(encoding="utf-8") + # longest old keys first to avoid partial substrings + order = sorted(by_file[rel], key=lambda x: -len(x[0])) + for old, new, _ in order: + if old == new: + continue + content = content.replace(f"'{old}'", f"'{new}'") + content = content.replace(f'"{old}"', f'"{new}"') + fpath.write_text(content, encoding="utf-8") + print(f"WROTE: {rel}") + + # Add new keys to locales + for _, old, new, _c in all_changes: + if old == new: + continue + if new in zh and zh.get(new) != zh.get(old): + pass + if old not in zh: + continue + zh[new] = zh[old] + if old in en: + en[new] = en[old] + if old in ar: + ar[new] = ar[old] + + for path, data in [ + (zh_path, zh), + (REPO / "src/i18n/locales/en.json", en), + (REPO / "src/i18n/locales/ar.json", ar), + ]: + path.write_text( + # Preserve key order; only append/overwrite entries from this script. + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print("LOCALES updated.", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/company.js b/src/api/company.js index 0b390d0..b50d2f4 100755 --- a/src/api/company.js +++ b/src/api/company.js @@ -263,6 +263,22 @@ export function getUsercenterAuthorizeUrl(params) { }) } +export function getAppcenterGoods(params) { + return fetch({ + url: '/company/appcenter/goods', + method: 'get', + params + }) +} + +export function getAppcenterUrl(params) { + return fetch({ + url: '/company/appcenter/url', + method: 'get', + params + }) +} + export function loginUsercenter(params) { return fetch({ url: '/company/usercenter/login', diff --git a/src/api/decoration.js b/src/api/decoration.js new file mode 100644 index 0000000..517b754 --- /dev/null +++ b/src/api/decoration.js @@ -0,0 +1,75 @@ +/** + * Copyright © ShopeX (http://www.shopex.cn). All rights reserved. + * 装修域 API(ThemeBundle 对齐):Web 端商城导航菜单等 + */ +import { fetch } from './request' + +// -------- Web 端商城导航菜单 -------- +export function getMenuList(params) { + return fetch({ + url: '/web-menus', + method: 'get', + params + }) +} + +export function getMenuDetail(id) { + return fetch({ + url: '/web-menus/' + id, + method: 'get' + }) +} + +export function createMenu(params) { + return fetch({ + url: '/web-menus', + method: 'post', + params + }) +} + +export function updateMenu(id, params) { + return fetch({ + url: '/web-menus/' + id, + method: 'put', + params + }) +} + +export function removeMenu(id) { + return fetch({ + url: '/web-menus/' + id, + method: 'delete' + }) +} + +export function createMenuItem(menuId, params) { + return fetch({ + url: '/web-menus/' + menuId + '/items', + method: 'post', + params + }) +} + +export function updateMenuItem(menuId, itemId, params) { + return fetch({ + url: '/web-menus/' + menuId + '/items/' + itemId, + method: 'put', + params + }) +} + +export function removeMenuItem(menuId, itemId) { + return fetch({ + url: '/web-menus/' + menuId + '/items/' + itemId, + method: 'delete' + }) +} + +export function sortMenuItems(menuId, sorts) { + return fetch({ + url: '/web-menus/' + menuId + '/items/sort', + method: 'put', + params: { sorts } + }) +} diff --git a/src/api/pickuplocation.js b/src/api/pickuplocation.js index 847396b..2c01c37 100755 --- a/src/api/pickuplocation.js +++ b/src/api/pickuplocation.js @@ -49,7 +49,7 @@ export function deleteZitiLocation(id) { // 自提点关联门店 export function bindZitiLocation(params) { return fetch({ - url: `pickuplocation/reldistributor`, + url: '/pickuplocation/reldistributor', method: 'post', params }) @@ -58,7 +58,7 @@ export function bindZitiLocation(params) { // 自提点取消关联门店 export function unbindZitiLocation(params) { return fetch({ - url: `pickuplocation/reldistributor/cancel`, + url: '/pickuplocation/reldistributor/cancel', method: 'post', params }) diff --git a/src/api/webtemplate.js b/src/api/webtemplate.js index bc492d6..dba035e 100755 --- a/src/api/webtemplate.js +++ b/src/api/webtemplate.js @@ -4,11 +4,19 @@ */ import { fetch } from './request' +function normalizeTemplateParams(params = {}) { + const nextParams = { ...params } + if (nextParams.distributor_id === undefined) { + delete nextParams.distributor_id + } + return nextParams +} + export const fetchTemplateList = (params) => { return fetch({ url: '/pctemplate/lists', method: 'get', - params + params: normalizeTemplateParams(params) }) } @@ -16,7 +24,7 @@ export const addPageTemplate = (data) => { return fetch({ url: '/pctemplate/add', method: 'post', - params: data + params: normalizeTemplateParams(data) }) } @@ -24,7 +32,7 @@ export const editPageTemplate = (data) => { return fetch({ url: '/pctemplate/edit', method: 'put', - params: data + params: normalizeTemplateParams(data) }) } @@ -43,6 +51,23 @@ export const getTemplateContent = (params) => { }) } +export const getDecorationContent = (params) => { + return fetch({ + url: '/pctemplate/getDecorationContent', + method: 'get', + params + }) +} + +/** 头/尾或全局装修块:按 page_name 查询单条 content(params 为 JSON 字符串) */ +export const getHeaderOrFooter = (params) => { + return fetch({ + url: '/pctemplate/getHeaderOrFooter', + method: 'get', + params + }) +} + // 保存页面模版装修内容 export const saveTemplateContent = (params) => { return fetch({ diff --git a/src/bootstrap/index.js b/src/bootstrap/index.js index 7de8307..34d2196 100755 --- a/src/bootstrap/index.js +++ b/src/bootstrap/index.js @@ -18,10 +18,13 @@ import { VuePrototype } from '@/utils' import { install as Finder } from './finder' import { install as Component } from './component' import { i18n } from '@/i18n' +import { installLucideGlobal } from '@/components/sp-icon/lucide-registry' async function bootstrap() { Vue.use(API) + installLucideGlobal(Vue) + Vue.use(ElementUI) Vue.use(Filter) diff --git a/src/components/sp-decorate/index.vue b/src/components/sp-decorate/index.vue index 4fd9152..7665ebd 100755 --- a/src/components/sp-decorate/index.vue +++ b/src/components/sp-decorate/index.vue @@ -142,7 +142,9 @@ export default { if (w) return w if (String(this.scene) === '1005' && key === 'goods') { return ( - this.widgets.find((item) => String(item.config?.name || '').toLowerCase() === 'goodscard') || + this.widgets.find( + (item) => String(item.config?.name || '').toLowerCase() === 'goodscard' + ) || this.widgets.find((item) => (item.name || '').toLowerCase() === 'goods') || null ) diff --git a/src/components/sp-dialog-plus/main.vue b/src/components/sp-dialog-plus/main.vue index 2f0eef3..4d8e27d 100755 --- a/src/components/sp-dialog-plus/main.vue +++ b/src/components/sp-dialog-plus/main.vue @@ -15,12 +15,11 @@ - - +
@@ -69,15 +68,15 @@ export default { }) }, width: { - type: String, - default: '1008px' + type: String }, value: { type: Object, default: () => ({}) }, size: { - type: String + type: String, + default: 'medium' }, isShow: Boolean }, @@ -89,7 +88,7 @@ export default { small: '800px', mini: '600px' } - return SIZE[this.size] || '1200px' + return this.width || SIZE[this.size] || '1200px' }, computedButtonCancel() { return { diff --git a/src/components/sp-dialog-plus/plugin.js b/src/components/sp-dialog-plus/plugin.js index e958d89..d684e03 100755 --- a/src/components/sp-dialog-plus/plugin.js +++ b/src/components/sp-dialog-plus/plugin.js @@ -9,7 +9,7 @@ export default { install(Vue, options = {}) { const Ctor = Vue.extend(Main) - const fn = (value) => { + const fn = (value, parent) => { const data = { buttonCancel: value.buttonCancel, buttonConfirm: value.buttonConfirm, @@ -19,6 +19,7 @@ export default { title: value.title, content: value.content }, + ...(value.width ? { width: value.width } : {}), confirmBefore: value.confirmBefore || (async () => { @@ -27,6 +28,7 @@ export default { } return new Promise((resolve, reject) => { const vm = new Ctor({ + parent: parent || undefined, propsData: data, created() { const teardown = () => { @@ -40,6 +42,11 @@ export default { }) }, render(h) { + const inner = + data.value.content == null + ? [] + : [isFunction(data.value.content) ? h(data.value.content) : data.value.content] + // 作为 Main 的默认插槽传入(与 body 具名插槽在 main.vue 内一并渲染,避免二选一误判) return h( Main, { @@ -60,7 +67,7 @@ export default { } } }, - [isFunction(data.value.content) ? h(data.value.content) : data.value.content] + inner ) } }).$mount() @@ -68,11 +75,112 @@ export default { }) } - const $dialog = { - open: ({ title = '', content = null, confirmBefore, buttonCancel, buttonConfirm, size }) => - fn({ title, content, confirmBefore, buttonCancel, buttonConfirm, size }) + const openComponent = (component, options = {}, parent) => { + const { + title = '', + props = {}, + width, + size, + buttonCancel, + buttonConfirm, + confirmBefore + } = options + const data = { + buttonCancel, + buttonConfirm, + isShow: true, + size, + ...(width ? { width } : {}), + value: { + title, + content: null + } + } + + return new Promise((resolve) => { + let contentVm + const vm = new Ctor({ + parent: parent || undefined, + propsData: data, + created() { + const teardown = () => { + this.$destroy() + this.$el.remove() + } + this.$once('close', () => { + resolve(null) + teardown() + }) + this.$once('input', (val) => { + resolve(val) + teardown() + }) + }, + render(h) { + return h( + Main, + { + props: data, + on: { + close: () => { + this.$emit('close') + }, + confirm: (callback) => { + const submit = () => { + const dialogContent = this.$refs.dialogContent || contentVm + if (dialogContent && typeof dialogContent.submit === 'function') { + dialogContent.submit() + callback() + return + } + callback() + } + if (confirmBefore) { + confirmBefore() + .then(submit) + .catch(() => callback()) + return + } + submit() + } + } + }, + [ + h(component, { + props, + on: { + close: (val) => { + this.$emit('input', val) + } + }, + ref: 'dialogContent' + }) + ] + ) + }, + mounted() { + contentVm = this.$refs.dialogContent + } + }).$mount() + document.body.appendChild(vm.$el) + }) } - Vue.prototype.$dialog = $dialog + const createDialogMethods = (parent) => ({ + open(componentOrOptions, options) { + if (options === undefined) { + return fn(componentOrOptions, parent) + } + const component = componentOrOptions.component || componentOrOptions + return openComponent(component, options, parent) + } + }) + + Object.defineProperty(Vue.prototype, '$dialog', { + get() { + return createDialogMethods(this) + }, + configurable: true + }) } } diff --git a/src/components/sp-form-plus/form-field.js b/src/components/sp-form-plus/form-field.js index 0ecea7f..b77e75f 100755 --- a/src/components/sp-form-plus/form-field.js +++ b/src/components/sp-form-plus/form-field.js @@ -3,7 +3,8 @@ * See LICENSE file for license details. */ import { isString } from '@/utils' -import { h } from 'vue' +// 与实例 render(h) 使用同一套 h,避免弹窗/动态挂载场景下与 import 的 h 混用导致子树不更新 +import { h as hCompat } from 'vue' import { PICKER_DATE_OPTIONS } from '@/consts' import { isFunction } from '@/utils/src/type-helper' import './form-field.scss' @@ -85,11 +86,10 @@ export default { this.componentProps.onChange?.(val, this.formData) }, - // 渲染 input 组件 - renderInput() { - return h('el-input', { + renderInput(props = {}, hCreate = hCompat) { + return hCreate('el-input', { attrs: { - ...this.componentProps, + ...props, size: this.size }, props: { @@ -100,12 +100,34 @@ export default { } }) }, - // 渲染 select 组件 - renderSelect(props = {}) { - const options = (props.options || []).map((option) => - h('el-option', { + renderTextarea(props = {}, hCreate = hCompat) { + return this.renderInput( + { + rows: 3, + ...props, + type: 'textarea' + }, + hCreate + ) + }, + renderDivider(props = {}) { + const label = props.label || this.label + return
{label}
+ }, + // 渲染 select 组件(options 仅用于生成 el-option 子节点,不可传入 el-select) + // style/class 须放在 vnode 根上;el-option 的 key 须为 vnode.key(勿放在 props 内,否则弹窗/表格内可能不渲染) + // hCreate 须传入 render(h) 的 h,保证与父级同一渲染上下文(弹窗内内置 select 必传) + renderSelect(props = {}, hCreate = hCompat) { + const { + options: rawOptions = [], + style: selectStyle, + class: selectClass, + ...selectRest + } = props + const optionVnodes = (rawOptions || []).map((option, idx) => + hCreate('el-option', { + key: option.value != null ? String(option.value) : `opt-${idx}`, props: { - key: option.value, label: option.label, value: option.value, disabled: option.disabled @@ -113,28 +135,31 @@ export default { }) ) - return h( + return hCreate( 'el-select', { + class: selectClass, + style: selectStyle, attrs: { size: this.size }, props: { value: this.modelValue, placeholder: this.$t('1bb56920.708c9d') + (this.label || '').replace(/:$/, ''), - ...props + popperAppendToBody: true, + ...selectRest }, on: { input: this.handleInput } }, - options + optionVnodes ) }, // 渲染 radio 组件 - renderRadio(props = {}) { + renderRadio(props = {}, hCreate = hCompat) { const radios = (props.options || []).map((option) => - h( + hCreate( 'el-radio', { props: { @@ -146,7 +171,7 @@ export default { ) ) - return h( + return hCreate( 'el-radio-group', { props: { @@ -161,10 +186,10 @@ export default { ) }, // 渲染 checkbox 组件(el-checkbox-group 的 value 必须为数组,否则会出现勾选一个则全部勾选等问题) - renderCheckbox(props = {}) { + renderCheckbox(props = {}, hCreate = hCompat) { const { options = [], ...restProps } = props const checkboxes = options.map((option) => - h( + hCreate( 'el-checkbox', { props: { @@ -181,7 +206,7 @@ export default { ? [].concat(this.modelValue) : [] - return h( + return hCreate( 'el-checkbox-group', { props: { @@ -196,8 +221,8 @@ export default { ) }, // 渲染 button 组件 - renderButton(props = {}) { - return h( + renderButton(props = {}, hCreate = hCompat) { + return hCreate( 'el-button', { props: { @@ -263,8 +288,8 @@ export default { renderImagePicker(props = {}) { return }, - renderCascader(props = {}) { - return h('el-cascader', { + renderCascader(props = {}, hCreate = hCompat) { + return hCreate('el-cascader', { attrs: { ...props, size: this.size || 'small' @@ -292,7 +317,7 @@ export default { }, // 渲染 upload 组件 - renderUpload(props = {}) { + renderUpload(props = {}, hCreate = hCompat) { const uploadProps = { action: props.action || '', 'auto-upload': props.autoUpload, @@ -301,16 +326,16 @@ export default { ...props } - return h('div', {}, [ - h('span', { style: {} }, props.title), - h( + return hCreate('div', {}, [ + hCreate('span', { style: {} }, props.title), + hCreate( 'el-upload', { class: props.class || '', props: uploadProps }, [ - h( + hCreate( 'el-button', { props: { @@ -325,24 +350,27 @@ export default { ]) }, - // 获取组件渲染函数 - getComponentRender() { + // 获取组件渲染函数(传入 render(h) 的 h,与当前实例同一渲染上下文) + getComponentRender(hRender) { + const hFn = hRender || hCompat if (isString(this.component)) { const type = this.component.toLowerCase() const renderMap = { - button: this.renderButton, - checkbox: this.renderCheckbox, - datepicker: this.renderDatePicker, - datetimepicker: this.renderDateTimePicker, - input: this.renderInput, - imagepicker: this.renderImagePicker, - radio: this.renderRadio, - select: this.renderSelect, - switch: this.renderSwitch, - upload: this.renderUpload, - cascader: this.renderCascader + button: (props) => this.renderButton(props, hFn), + checkbox: (props) => this.renderCheckbox(props, hFn), + datepicker: (props) => this.renderDatePicker(props), + datetimepicker: (props) => this.renderDateTimePicker(props), + divider: (props) => this.renderDivider(props), + input: (props) => this.renderInput(props, hFn), + imagepicker: (props) => this.renderImagePicker(props), + radio: (props) => this.renderRadio(props, hFn), + select: (props) => this.renderSelect(props, hFn), + switch: () => this.renderSwitch(), + textarea: (props) => this.renderTextarea(props, hFn), + upload: (props) => this.renderUpload(props, hFn), + cascader: (props) => this.renderCascader(props, hFn) } - return renderMap[type] || this.renderInput + return renderMap[type] || ((props) => this.renderInput(props, hFn)) } if (typeof this.component === 'function') { @@ -351,12 +379,12 @@ export default { value: this.modelValue, props: this.componentProps, onInput: this.handleInput, - h, + h: hFn, formData: this.formData }) } - return this.renderInput + return (props) => this.renderInput(props, hFn) } }, watch: { @@ -368,10 +396,6 @@ export default { } }, render(h) { - if (this.component === 'group') { - return
{this.label}
- } - // 检查是否应该显示该字段 const shouldShow = this.isShow ? this.isShow(this.modelValue) : true if (!shouldShow) { @@ -379,8 +403,14 @@ export default { return null } - // 获取对应的渲染函数 - const renderComponent = this.getComponentRender() + const isDivider = + isString(this.component) && ['group', 'divider'].includes(this.component.toLowerCase()) + if (isDivider) { + return this.renderDivider(this.componentProps) + } + + // 获取对应的渲染函数(与当前 render 共用 h) + const renderComponent = this.getComponentRender(h) // 渲染表单项 const divProps = { diff --git a/src/components/sp-form-plus/form-field.scss b/src/components/sp-form-plus/form-field.scss index f80ad92..0b839db 100755 --- a/src/components/sp-form-plus/form-field.scss +++ b/src/components/sp-form-plus/form-field.scss @@ -5,27 +5,29 @@ .sp-form-plus { .form-field { margin-bottom: 22px; + } - &-group { - &:before { - position: absolute; - top: 11px; - bottom: 11px; - left: 0; - width: 2px; - background-color: var(--primary); - content: ''; - } - - padding: 8px; - color: #333; - // font-weight: 500; - font-size: 15px; - position: relative; - border-bottom: 1px solid #f1f2f5; - margin-bottom: 18px; + .form-field-divider { + &:before { + position: absolute; + top: 11px; + bottom: 11px; + left: 0; + width: 2px; + background-color: #298dff; + content: ''; } + padding: 6px 0 6px 8px; + color: #333; + font-weight: 500; + font-size: 16px; + position: relative; + border-bottom: 1px solid #f1f2f5; + margin-bottom: 10px; + } + + .form-field { // 可以添加更多样式 .el-form-item { margin-bottom: 0; diff --git a/src/components/sp-form-plus/index.vue b/src/components/sp-form-plus/index.vue index 056a0d8..b710876 100755 --- a/src/components/sp-form-plus/index.vue +++ b/src/components/sp-form-plus/index.vue @@ -11,13 +11,22 @@ 'sp-form-plus--search-form': formType === 'searchForm', 'sp-form-plus--display-form': formType === 'displayForm', 'sp-form-plus--inline': inline, - 'sp-form-plus--collapsed': formType === 'searchForm' && !extend + 'sp-form-plus--collapsed': formType === 'searchForm' && !extend, + 'sp-form-plus--label-top': formType === 'searchForm' && labelPosition === 'top', + 'sp-form-plus--actions-bottom': isSearchActionsBottom }" :model="formData" :label-width="labelWidth" :label-position="labelPosition" + @submit.native.prevent="handleSubmit" > -
+
@@ -145,6 +157,11 @@ export default { type: String, default: 'horizontal' }, + searchActionsLayout: { + type: String, + default: '', + validator: (value) => ['', 'inline', 'bottom-right'].includes(value) + }, submitLoading: { type: Boolean, default: false @@ -169,7 +186,12 @@ export default { data() { const formData = {} this.formItems - .filter((item) => item.component !== 'group') + .filter((item) => { + const isLayoutComponent = + typeof item.component === 'string' && + ['group', 'divider'].includes(item.component.toLowerCase()) + return !isLayoutComponent + }) .forEach((item) => { formData[item.fieldName] = typeof item.value === 'undefined' @@ -205,6 +227,14 @@ export default { // 检查是否有表单项使用了 formItemClass hasFormItemClass() { return this.formItems.some((item) => item.formItemClass) + }, + effectiveSearchActionsLayout() { + if (this.formType !== 'searchForm') return 'inline' + if (this.searchActionsLayout) return this.searchActionsLayout + return this.labelPosition === 'top' ? 'bottom-right' : 'inline' + }, + isSearchActionsBottom() { + return this.formType === 'searchForm' && this.effectiveSearchActionsLayout === 'bottom-right' } }, watch: { @@ -407,6 +437,60 @@ export default { min-width: fit-content; } } + + &--label-top { + ::v-deep .el-form-item__label { + float: none; + display: block; + padding: 0 0 6px; + text-align: left; + line-height: 20px; + } + + ::v-deep .el-form-item__content { + margin-left: 0 !important; + line-height: normal; + } + } + + &--actions-bottom { + .sp-form-plus__layout-bottom { + display: flex; + flex-direction: column; + min-width: 0; + } + + .sp-form-plus__wrapper { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px 24px; + width: 100%; + + .form-field { + width: 100% !important; + max-width: none !important; + flex: initial !important; + } + } + + .sp-form-plus__actions-wrapper { + width: 100%; + min-width: 0; + } + + .sp-form-plus__actions { + width: 100%; + justify-content: flex-end; + align-items: flex-end; + padding-top: 0; + margin-top: 16px !important; + } + + .sp-form-plus__actions-btns { + display: flex; + justify-content: flex-end; + } + } &__actions { padding-top: 40px; display: flex; diff --git a/src/components/sp-icon/index.vue b/src/components/sp-icon/index.vue index e3f630d..f692617 100755 --- a/src/components/sp-icon/index.vue +++ b/src/components/sp-icon/index.vue @@ -10,16 +10,21 @@ class="flex items-center justify-center hover:bg-gray-100 transition-all duration-300 cursor-pointer" @click.stop="onButtonClick" > - +
- + + + diff --git a/src/components/sp-path-selector/config.js b/src/components/sp-path-selector/config.js new file mode 100644 index 0000000..dc582b1 --- /dev/null +++ b/src/components/sp-path-selector/config.js @@ -0,0 +1,27 @@ +export const PATH_SELECTOR_TYPES = [ + { name: 'goods', labelKey: 'd81d8932.9897d8' }, + { name: 'sale_category', labelKey: 'd81d8932.392d49' }, + { name: 'category', labelKey: 'd81d8932.b3ed9f' }, + { name: 'tag', labelKey: 'd81d8932.0f394b' }, + { name: 'article', labelKey: 'd81d8932.c75625' }, + { name: 'planting', labelKey: 'd81d8932.e8f87a' }, + { name: 'custom_page', labelKey: 'd81d8932.cee6eb' }, + { name: 'regactivity', labelKey: 'd81d8932.2bc045' }, + { name: 'purchase_activity', labelKey: 'd81d8932.dc7202' }, + { name: 'live', labelKey: 'd81d8932.7bbe8e' }, + { name: 'other_wxapp', labelKey: 'd81d8932.f5a0d7' }, + { name: 'lottery', labelKey: 'd81d8932.283be6' }, + { name: 'share_page', labelKey: 'd81d8932.787963' }, + { name: 'customer_service', labelKey: 'd81d8932.e7dea7' }, + { name: 'link', labelKey: 'd81d8932.59ceff' } +] + +export const PATH_SELECTOR_PLATFORM_TYPES = { + web: ['goods', 'sale_category', 'custom_page'] +} + +export function getPathSelectorTypes(platform) { + const names = PATH_SELECTOR_PLATFORM_TYPES[platform] + if (!names) return PATH_SELECTOR_TYPES + return PATH_SELECTOR_TYPES.filter((item) => names.includes(item.name)) +} diff --git a/src/components/sp-path-selector/index.vue b/src/components/sp-path-selector/index.vue new file mode 100644 index 0000000..1559718 --- /dev/null +++ b/src/components/sp-path-selector/index.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/src/components/sp-path-selector/path-selector.test.mjs b/src/components/sp-path-selector/path-selector.test.mjs new file mode 100644 index 0000000..5041235 --- /dev/null +++ b/src/components/sp-path-selector/path-selector.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import test from 'node:test' + +const root = process.cwd() +const read = (filePath) => readFileSync(path.resolve(root, filePath), 'utf8') + +test('web path selector is limited to web-supported link types', () => { + const config = read('src/components/sp-path-selector/config.js') + const menuForm = read('src/views/decoration/web-menu/components/MenuItemForm.vue') + + assert.match(config, /web:\s*\['goods',\s*'sale_category',\s*'custom_page'\]/) + assert.match(config, /function getPathSelectorTypes\(platform\)/) + assert.match(menuForm, / { + const selector = read('src/components/sp-path-selector/index.vue') + const dialog = read('src/components/sp-path-selector/PickerDialog.vue') + + assert.match(selector, /pathTypes\(\)\s*\{\s*return getPathSelectorTypes\(this\.platform\)/) + assert.match(selector, /pathTypes:\s*this\.pathTypes/) + assert.match(selector, /platform:\s*this\.platform/) + assert.match(selector, /!this\.pathTypes\.some\(\(item\) => item\.name === currentType\)/) + assert.match(dialog, /pathTypes:\s*\{\s*type:\s*Array/) + assert.match(dialog, /this\.pathTypes\.find\(\(item\) => item\.name === this\.pathType\)/) +}) + +test('web custom page path uses WebCustomPagePicker', () => { + const dialog = read('src/components/sp-path-selector/PickerDialog.vue') + + assert.match(dialog, /import WebCustomPagePicker/) + assert.match(dialog, /const PICKER_ADAPTERS =/) + assert.match(dialog, /custom_page:\s*\{[\s\S]*platforms:\s*\{[\s\S]*web:\s*\{[\s\S]*component:\s*WebCustomPagePicker/) + assert.match(dialog, /resolvePickerAdapter\(tabValue, platform\)/) + assert.match(dialog, /handlePickerClose\(row\)/) +}) diff --git a/src/components/sp-picker-plus/GoodsPicker.vue b/src/components/sp-picker-plus/GoodsPicker.vue new file mode 100644 index 0000000..fe3f9a7 --- /dev/null +++ b/src/components/sp-picker-plus/GoodsPicker.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/src/components/sp-video-picker/index.vue b/src/components/sp-picker-plus/VideoPicker.vue similarity index 90% rename from src/components/sp-video-picker/index.vue rename to src/components/sp-picker-plus/VideoPicker.vue index f20ff4a..8a9e2dd 100755 --- a/src/components/sp-video-picker/index.vue +++ b/src/components/sp-picker-plus/VideoPicker.vue @@ -19,9 +19,25 @@ width: 142px; height: 80px; border: 1px solid #d9d9d9; - margin: 0 10px 10px 0; + margin: 0 10px 0 0; position: relative; } + &.big { + .image-item { + width: 100%; + height: 180px; + margin: 0; + } + + .placeholder { + height: 180px; + } + } + &.multiple { + .image-item { + margin-bottom: 10px; + } + } .add-video { width: 80px; } @@ -32,11 +48,10 @@ align-items: center; justify-content: center; } - .icon-video { - font-size: 24px; + .picker-placeholder-icon { + width: 24px; + height: 24px; color: #d9d9d9; - line-height: initial; - // margin-top: 12px; } .add-text { font-size: 12px; @@ -102,8 +117,10 @@ + + diff --git a/src/components/sp-picker-plus/WebNavPicker.vue b/src/components/sp-picker-plus/WebNavPicker.vue new file mode 100644 index 0000000..68f21b6 --- /dev/null +++ b/src/components/sp-picker-plus/WebNavPicker.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/src/components/sp-picker-plus/index.js b/src/components/sp-picker-plus/index.js new file mode 100644 index 0000000..0eae9ca --- /dev/null +++ b/src/components/sp-picker-plus/index.js @@ -0,0 +1,4 @@ +export { default as VideoPicker } from './VideoPicker.vue' +export { default as WebNavPicker } from './WebNavPicker.vue' +export { default as GoodsPicker } from './GoodsPicker.vue' +export { default as WebCustomPagePicker } from './WebCustomPagePicker.vue' diff --git a/src/components/sp-picker/pickers/picker-article.vue b/src/components/sp-picker/pickers/picker-article.vue index e060b43..41608bb 100755 --- a/src/components/sp-picker/pickers/picker-article.vue +++ b/src/components/sp-picker/pickers/picker-article.vue @@ -91,7 +91,9 @@ export default { const raw = this.value?.data if (raw == null || raw === '') return const selectedIds = Array.isArray(raw) ? raw : [raw] - const selectRows = list.filter((item) => selectedIds.map(String).includes(String(item.article_id))) + const selectRows = list.filter((item) => + selectedIds.map(String).includes(String(item.article_id)) + ) if (!selectRows.length) return const finderTable = this.$refs.finder?.$refs?.finderTable if (!finderTable?.$refs?.finderTable) return diff --git a/src/components/sp-picker/pickers/picker-company.vue b/src/components/sp-picker/pickers/picker-company.vue index f8f624f..c533056 100755 --- a/src/components/sp-picker/pickers/picker-company.vue +++ b/src/components/sp-picker/pickers/picker-company.vue @@ -92,7 +92,12 @@ export default { key: 'disabled', width: 88, formatter: (value, row) => { - if (row == null || row.disabled === undefined || row.disabled === null || row.disabled === '') { + if ( + row == null || + row.disabled === undefined || + row.disabled === null || + row.disabled === '' + ) { return '—' } const d = row.disabled @@ -149,10 +154,7 @@ export default { if (id == null) { return } - const row = - typeof entry === 'object' && entry != null - ? { ...entry } - : { id } + const row = typeof entry === 'object' && entry != null ? { ...entry } : { id } const prev = this.selectedById[id] next[id] = prev && typeof prev === 'object' ? { ...prev, ...row } : row }) diff --git a/src/components/sp-picker/pickers/picker-coupon.vue b/src/components/sp-picker/pickers/picker-coupon.vue index 926e548..3e960fb 100755 --- a/src/components/sp-picker/pickers/picker-coupon.vue +++ b/src/components/sp-picker/pickers/picker-coupon.vue @@ -200,7 +200,9 @@ export default { const raw = this.value?.data if (raw == null || raw === '') return const selectedIds = Array.isArray(raw) ? raw : [raw] - const selectRows = list.filter((item) => selectedIds.map(String).includes(String(item.card_id))) + const selectRows = list.filter((item) => + selectedIds.map(String).includes(String(item.card_id)) + ) if (!selectRows.length) return const finderTable = this.$refs.finder?.$refs?.finderTable if (!finderTable?.$refs?.finderTable) return diff --git a/src/components/sp-picker/pickers/picker-image.vue b/src/components/sp-picker/pickers/picker-image.vue index d47b2e3..9c3fa07 100755 --- a/src/components/sp-picker/pickers/picker-image.vue +++ b/src/components/sp-picker/pickers/picker-image.vue @@ -171,15 +171,11 @@ /> -{{ - addGroupBtnText - }} - + {{ addGroupBtnText }} + -{{ - moveGroupBtnText - }} - + {{ moveGroupBtnText }} + @@ -204,10 +200,8 @@ 下载 --> -{{ - cancelAllBtnText - }} - + {{ cancelAllBtnText }} +
- - + + diff --git a/src/components/sp-table/selection.js b/src/components/sp-table/selection.js new file mode 100644 index 0000000..82729d3 --- /dev/null +++ b/src/components/sp-table/selection.js @@ -0,0 +1,19 @@ +export function getRowKey(row = {}, rowKey = 'id') { + if (typeof rowKey === 'function') return rowKey(row) + return row?.[rowKey] +} + +export function normalizeKey(value) { + return String(value ?? '').trim() +} + +export function isSameRow(left = {}, right = {}, rowKey = 'id') { + const leftKey = normalizeKey(getRowKey(left, rowKey)) + const rightKey = normalizeKey(getRowKey(right, rowKey)) + return Boolean(leftKey && rightKey && leftKey === rightKey) +} + +export function toggleSingleSelection(currentRow, row, rowKey = 'id') { + if (!row) return null + return currentRow && isSameRow(currentRow, row, rowKey) ? null : row +} diff --git a/src/components/sp-table/selection.mjs b/src/components/sp-table/selection.mjs new file mode 100644 index 0000000..82729d3 --- /dev/null +++ b/src/components/sp-table/selection.mjs @@ -0,0 +1,19 @@ +export function getRowKey(row = {}, rowKey = 'id') { + if (typeof rowKey === 'function') return rowKey(row) + return row?.[rowKey] +} + +export function normalizeKey(value) { + return String(value ?? '').trim() +} + +export function isSameRow(left = {}, right = {}, rowKey = 'id') { + const leftKey = normalizeKey(getRowKey(left, rowKey)) + const rightKey = normalizeKey(getRowKey(right, rowKey)) + return Boolean(leftKey && rightKey && leftKey === rightKey) +} + +export function toggleSingleSelection(currentRow, row, rowKey = 'id') { + if (!row) return null + return currentRow && isSameRow(currentRow, row, rowKey) ? null : row +} diff --git a/src/components/sp-table/selection.test.mjs b/src/components/sp-table/selection.test.mjs new file mode 100644 index 0000000..5196410 --- /dev/null +++ b/src/components/sp-table/selection.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { toggleSingleSelection, isSameRow } from './selection.mjs' + +test('single selection replaces previous row', () => { + const first = { id: 1, name: '测试' } + const second = { id: 2, name: '620菜单' } + + assert.equal(toggleSingleSelection(first, second), second) +}) + +test('single selection toggles current row off', () => { + const current = { id: '2', name: '620菜单' } + const row = { id: 2, name: '620菜单' } + + assert.equal(toggleSingleSelection(current, row), null) +}) + +test('row comparison accepts custom row key function', () => { + assert.equal( + isSameRow({ code: 620 }, { code: '620' }, (row) => row.code), + true + ) +}) diff --git a/src/components/sp-translate-popup/index.vue b/src/components/sp-translate-popup/index.vue index b3a8582..f5d4236 100644 --- a/src/components/sp-translate-popup/index.vue +++ b/src/components/sp-translate-popup/index.vue @@ -13,11 +13,7 @@

{{ $t('41f42a3e.8085da') }}

- + {{ lang.label }} diff --git a/src/components/sp-video-picker/index.scss b/src/components/sp-video-picker/index.scss deleted file mode 100755 index 27982ea..0000000 --- a/src/components/sp-video-picker/index.scss +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Copyright © ShopeX (http://www.shopex.cn). All rights reserved. - * See LICENSE file for license details. - */ diff --git a/src/components/sp-web-decoration/adapters/apiPersistence.js b/src/components/sp-web-decoration/adapters/apiPersistence.js new file mode 100644 index 0000000..a5b2e63 --- /dev/null +++ b/src/components/sp-web-decoration/adapters/apiPersistence.js @@ -0,0 +1,466 @@ +/** + * PC 模板装修:与 ThemeBundle pctemplate 接口对接,存取 ECX 编辑器 DSL。 + * 读取统一走 getDecorationContent,按 page_name 区分 page/header/footer。 + * 保存仍维持 page 与 header/footer 分开调用,兼容现有后端存储。 + */ +import { getDecorationContent, saveTemplateContent, saveHeaderOrFooter } from '@/api/webtemplate' +import { + createHomeDsl, + createCustomDsl, + createProductListDsl, + createGlobalDsl, + serializeDsl, + deserializeDsl, + ensureGlobalDslDefaults, + createHeaderDsl, + createFooterDsl, + splitGlobalDsl +} from '../utils/dsl.js' +import { createGlobalSections } from '../definitions/documents/global.js' + +export const DSL_ROW_TYPE = 'ECX_SP_WEB_DECORATION_DSL_V1' +const LEGACY_GLOBAL_DECORATION_PAGE_NAME = 'ecx_sp_web_decoration_global' +export const HEADER_DECORATION_PAGE_NAME = 'header' +export const FOOTER_DECORATION_PAGE_NAME = 'footer' + +function createPageFallback(templateId, scene, pageType = 'home') { + if (pageType === 'custom') { + return createCustomDsl({ pageId: String(templateId || ''), scene }) + } + if (pageType === 'product_list') { + return createProductListDsl({ pageId: String(templateId || 'product_list'), scene }) + } + const dsl = createHomeDsl({ pageId: String(templateId || ''), scene }) + dsl.pageType = pageType + return dsl +} + +function createGlobalFallback() { + return createGlobalDsl(createGlobalSections()) +} + +function createHeaderFallback() { + return createHeaderDsl() +} + +function createFooterFallback() { + return createFooterDsl() +} + +function normalizePageDsl(raw, templateId, scene, pageType = 'home') { + const fallback = createPageFallback(templateId, scene, pageType) + if (raw == null) return fallback + let payload = raw + if (typeof raw === 'string') { + try { + payload = JSON.parse(raw) + } catch (e) { + return fallback + } + } + if (typeof payload !== 'object' || payload === null) return fallback + try { + const d = deserializeDsl(payload) + if (!d || typeof d !== 'object') return fallback + if (typeof d.sections !== 'object' || d.sections === null) return fallback + if (!Array.isArray(d.order)) d.order = [] + d.pageType = fallback.pageType + d.pageId = fallback.pageId + if (!d.meta || typeof d.meta !== 'object') d.meta = { ...(fallback.meta || {}) } + return d + } catch (e) { + console.warn('[decoration] normalizePageDsl', e) + return fallback + } +} + +function normalizeGlobalDsl(raw) { + const fallback = createGlobalFallback() + if (raw == null) return fallback + let payload = raw + if (typeof raw === 'string') { + try { + payload = JSON.parse(raw) + } catch (e) { + return fallback + } + } + if (typeof payload !== 'object' || payload === null) return fallback + try { + const d = deserializeDsl(payload) + if (!d || typeof d !== 'object') return fallback + if (d.pageType !== 'global') return fallback + const sections = d.sections || {} + const keys = Object.keys(sections) + if (keys.length === 0) return fallback + + if (!Array.isArray(d.order)) d.order = [] + if (d.order.length === 0) { + const preferred = (fallback.order || []).filter((id) => sections[id]) + d.order = preferred.length ? preferred : keys + } + return ensureGlobalDslDefaults(d) + } catch (e) { + console.warn('[decoration] normalizeGlobalDsl', e) + return fallback + } +} + +function normalizeScopedDsl(raw, fallback, pageType) { + if (raw == null) return fallback + let payload = raw + if (typeof raw === 'string') { + try { + payload = JSON.parse(raw) + } catch (e) { + return fallback + } + } + if (typeof payload !== 'object' || payload === null) return fallback + try { + const d = deserializeDsl(payload) + if (!d || typeof d !== 'object') return fallback + if (d.pageType !== pageType) return fallback + if (typeof d.sections !== 'object' || d.sections === null) return fallback + if (!Array.isArray(d.order)) d.order = fallback.order + if (!d.pageId) d.pageId = fallback.pageId + return d + } catch (e) { + console.warn(`[decoration] normalize${pageType}Dsl`, e) + return fallback + } +} + +function unwrapPayload(res) { + let current = res + while (current && typeof current === 'object') { + if (Array.isArray(current)) { + return current + } + + if ( + current.data !== undefined && + current.headers !== undefined && + current.status !== undefined && + current.config !== undefined + ) { + current = current.data + continue + } + + if ( + current.data !== undefined && + current.data !== null && + current.id === undefined && + current.name === undefined && + current.config === undefined + ) { + current = current.data + continue + } + + break + } + return current == null ? null : current +} + +function parsePageDslRows(payload, templateId, scene, pageType = 'home') { + const rows = Array.isArray(payload) + ? payload + : payload && typeof payload === 'object' + ? [payload] + : [] + if (!rows.length) return null + + for (const row of rows) { + console.log('[sp-web-decoration] page config payload', { + templateId, + rowId: row?.id, + rowName: row?.name, + config: row?.config + }) + let cfg + try { + cfg = JSON.parse(row?.config || '[]') + } catch (e) { + console.warn('[sp-web-decoration] page config parse failed', { + templateId, + rowId: row?.id, + error: e?.message + }) + continue + } + const target = Array.isArray(cfg) + ? cfg.find((item) => item && item.type === DSL_ROW_TYPE) + : cfg && cfg.type === DSL_ROW_TYPE && cfg.dsl + ? cfg + : cfg && cfg.pageType && cfg.sections + ? { dsl: cfg } + : null + console.log('[sp-web-decoration] page config parsed target', { + templateId, + rowId: row?.id, + isArray: Array.isArray(cfg), + hasTarget: Boolean(target), + targetDsl: target?.dsl + }) + if (!target || !target.dsl) continue + return { + dsl: normalizePageDsl(target.dsl, templateId, scene, pageType), + contentRowId: row.id || 0, + source: 'api' + } + } + + return null +} + +function parseScopedDslRow(row, fallback, pageType) { + if (!row) return null + const candidate = row?.config || row?.params || row?.dsl || row + return { + dsl: normalizeScopedDsl(candidate, fallback, pageType), + source: 'api' + } +} + +function parseLegacyGlobalDslRow(row) { + if (!row) return null + const candidate = row?.config || row?.params || row?.dsl || row + return { + dsl: normalizeGlobalDsl(candidate), + source: 'api' + } +} + +function createPageLoadFallback(templateId, scene, pageType = 'home') { + return { + dsl: createPageFallback(templateId, scene, pageType), + contentRowId: 0, + source: 'default' + } +} + +export async function loadPageDslFromApi({ templateId, scene, pageType = 'home' }) { + const fallback = { + dsl: createPageFallback(templateId, scene, pageType), + contentRowId: 0, + source: 'default' + } + if (!templateId) return fallback + + try { + const raw = await getDecorationContent({ + page_name: 'page', + theme_pc_template_id: templateId + }) + const list = unwrapPayload(raw) + return parsePageDslRows(list, templateId, scene, pageType) || fallback + } catch (e) { + console.warn('[decoration] loadPageDslFromApi', e) + } + return fallback +} + +export async function savePageDslToApi({ + templateId, + contentRowId, + dsl, + scene, + pageType = 'home' +}) { + const normalizedDsl = normalizePageDsl(dsl, templateId, scene, pageType) + const payload = { + type: DSL_ROW_TYPE, + dsl: serializeDsl(normalizedDsl) + } + const config = JSON.stringify([payload]) + const body = { + theme_pc_template_id: templateId, + config + } + + if (contentRowId) { + body.id = contentRowId + } + + await saveTemplateContent(body) + + return { + dsl: normalizedDsl, + contentRowId: contentRowId || 0 + } +} + +async function loadLegacyGlobalDslFromApi() { + try { + const raw = await getDecorationContent({ + page_name: LEGACY_GLOBAL_DECORATION_PAGE_NAME + }) + const payload = unwrapPayload(raw) + if (!payload) return { dsl: createGlobalFallback(), source: 'default' } + const candidate = payload?.config || payload?.params || payload?.dsl || payload + return { + dsl: normalizeGlobalDsl(candidate), + source: 'api' + } + } catch (e) { + console.warn('[decoration] loadLegacyGlobalDslFromApi', e) + return { dsl: createGlobalFallback(), source: 'default' } + } +} + +export async function loadHeaderDslFromApi() { + try { + const raw = await getDecorationContent({ + page_name: HEADER_DECORATION_PAGE_NAME, + page_type: HEADER_DECORATION_PAGE_NAME + }) + const payload = unwrapPayload(raw) + if (!payload) return { dsl: createHeaderFallback(), source: 'default' } + const candidate = payload?.config || payload?.params || payload?.dsl || payload + return { + dsl: normalizeScopedDsl(candidate, createHeaderFallback(), 'header'), + source: 'api' + } + } catch (e) { + console.warn('[decoration] loadHeaderDslFromApi', e) + return { dsl: createHeaderFallback(), source: 'default' } + } +} + +export async function loadFooterDslFromApi() { + try { + const raw = await getDecorationContent({ + page_name: FOOTER_DECORATION_PAGE_NAME, + page_type: FOOTER_DECORATION_PAGE_NAME + }) + const payload = unwrapPayload(raw) + if (!payload) return { dsl: createFooterFallback(), source: 'default' } + const candidate = payload?.config || payload?.params || payload?.dsl || payload + return { + dsl: normalizeScopedDsl(candidate, createFooterFallback(), 'footer'), + source: 'api' + } + } catch (e) { + console.warn('[decoration] loadFooterDslFromApi', e) + return { dsl: createFooterFallback(), source: 'default' } + } +} + +export async function loadHeaderFooterDslFromApi() { + const [header, footer] = await Promise.all([loadHeaderDslFromApi(), loadFooterDslFromApi()]) + if (header.source !== 'default' || footer.source !== 'default') { + return { + headerDsl: header.dsl, + footerDsl: footer.dsl, + source: 'api' + } + } + + const legacyGlobal = await loadLegacyGlobalDslFromApi() + if (legacyGlobal.source !== 'default') { + return { + ...splitGlobalDsl(legacyGlobal.dsl), + source: 'legacy-global' + } + } + + return { + headerDsl: header.dsl, + footerDsl: footer.dsl, + source: 'default' + } +} + +export async function loadDecorationContentFromApi({ templateId, scene, pageType = 'home' }) { + const fallbackPage = createPageLoadFallback(templateId, scene, pageType) + if (!templateId) { + return { + page: fallbackPage, + headerDsl: createHeaderFallback(), + footerDsl: createFooterFallback(), + source: 'default' + } + } + + try { + const [page, headerFooter] = await Promise.all([ + loadPageDslFromApi({ templateId, scene, pageType }), + loadHeaderFooterDslFromApi() + ]) + if (headerFooter.source === 'legacy-global') { + return { + page, + headerDsl: headerFooter.headerDsl, + footerDsl: headerFooter.footerDsl, + source: 'legacy-global' + } + } + if (page.source !== 'default' || headerFooter.source !== 'default') { + return { + page, + headerDsl: headerFooter.headerDsl, + footerDsl: headerFooter.footerDsl, + source: 'api' + } + } + return { + page, + headerDsl: headerFooter.headerDsl, + footerDsl: headerFooter.footerDsl, + source: 'default' + } + } catch (e) { + console.warn('[decoration] loadDecorationContentFromApi fallback', e) + const [page, headerFooter] = await Promise.all([ + loadPageDslFromApi({ templateId, scene, pageType }), + loadHeaderFooterDslFromApi() + ]) + return { + page, + headerDsl: headerFooter.headerDsl, + footerDsl: headerFooter.footerDsl, + source: headerFooter.source || page.source + } + } +} + +function saveScopedDslToApi({ pageName, dsl }) { + const payload = { + page_name: pageName, + config: JSON.stringify(serializeDsl(dsl)) + } + return saveHeaderOrFooter(payload) +} + +export async function saveHeaderDslToApi({ dsl }) { + await saveScopedDslToApi({ + pageName: HEADER_DECORATION_PAGE_NAME, + dsl + }) + return { + dsl: normalizeScopedDsl(dsl, createHeaderFallback(), 'header') + } +} + +export async function saveFooterDslToApi({ dsl }) { + await saveScopedDslToApi({ + pageName: FOOTER_DECORATION_PAGE_NAME, + dsl + }) + return { + dsl: normalizeScopedDsl(dsl, createFooterFallback(), 'footer') + } +} + +export default { + loadDecorationContentFromApi, + loadPageDslFromApi, + savePageDslToApi, + loadHeaderDslFromApi, + saveHeaderDslToApi, + loadFooterDslFromApi, + saveFooterDslToApi, + loadHeaderFooterDslFromApi +} diff --git a/src/components/sp-web-decoration/adapters/homeDecoration.js b/src/components/sp-web-decoration/adapters/homeDecoration.js new file mode 100644 index 0000000..2b579e1 --- /dev/null +++ b/src/components/sp-web-decoration/adapters/homeDecoration.js @@ -0,0 +1,148 @@ +import { + createHomeDsl, + serializeDsl, + deserializeDsl, + createHeaderDsl, + createFooterDsl, + splitGlobalDsl +} from '../utils/dsl.js' + +const STORAGE_PREFIX = 'decoration:home:' +const GLOBAL_STORAGE_KEY = 'decoration:global' +const HEADER_STORAGE_KEY = 'decoration:header' +const FOOTER_STORAGE_KEY = 'decoration:footer' + +function getStorage() { + if (typeof sessionStorage !== 'undefined') { + return sessionStorage + } + if (typeof globalThis !== 'undefined' && globalThis.sessionStorage) { + return globalThis.sessionStorage + } + return null +} + +function getStorageKey(templateId) { + return `${STORAGE_PREFIX}${templateId || 'draft'}` +} + +export async function loadHomeDecoration({ templateId = '', scene = '1001' } = {}) { + const fallbackDsl = createHomeDsl({ + pageId: String(templateId || ''), + scene + }) + const storage = getStorage() + if (!storage) { + return { dsl: fallbackDsl, source: 'default' } + } + + const rawValue = storage.getItem(getStorageKey(templateId)) + if (!rawValue) { + return { dsl: fallbackDsl, source: 'default' } + } + + try { + return { + dsl: deserializeDsl(JSON.parse(rawValue)), + source: 'session' + } + } catch (error) { + return { dsl: fallbackDsl, source: 'default' } + } +} + +export async function saveHomeDecoration({ templateId = '', dsl }) { + const storage = getStorage() + + if (storage) { + storage.setItem(getStorageKey(templateId), JSON.stringify(serializeDsl(dsl))) + } + + return { + dsl, + persisted: Boolean(storage), + messageKey: '4c11e15d.ebf4e4' + } +} + +export async function loadHeaderFooterDecoration() { + const fallbackHeaderDsl = createHeaderDsl() + const fallbackFooterDsl = createFooterDsl() + const storage = getStorage() + if (!storage) { + return { + headerDsl: fallbackHeaderDsl, + footerDsl: fallbackFooterDsl, + source: 'default' + } + } + + const rawHeaderValue = storage.getItem(HEADER_STORAGE_KEY) + const rawFooterValue = storage.getItem(FOOTER_STORAGE_KEY) + if (rawHeaderValue || rawFooterValue) { + try { + const headerDsl = rawHeaderValue + ? deserializeDsl(JSON.parse(rawHeaderValue)) + : fallbackHeaderDsl + const footerDsl = rawFooterValue + ? deserializeDsl(JSON.parse(rawFooterValue)) + : fallbackFooterDsl + return { headerDsl, footerDsl, source: 'session' } + } catch (error) { + return { + headerDsl: fallbackHeaderDsl, + footerDsl: fallbackFooterDsl, + source: 'default' + } + } + } + + const rawValue = storage.getItem(GLOBAL_STORAGE_KEY) + if (!rawValue) { + return { + headerDsl: fallbackHeaderDsl, + footerDsl: fallbackFooterDsl, + source: 'default' + } + } + + try { + const { headerDsl, footerDsl } = splitGlobalDsl(deserializeDsl(JSON.parse(rawValue))) + return { + headerDsl, + footerDsl, + source: 'session' + } + } catch (error) { + return { + headerDsl: fallbackHeaderDsl, + footerDsl: fallbackFooterDsl, + source: 'default' + } + } +} + +export async function saveHeaderFooterDecoration({ dsl, headerDsl, footerDsl }) { + const storage = getStorage() + const splitDsl = dsl ? splitGlobalDsl(dsl) : {} + const nextHeaderDsl = headerDsl || splitDsl.headerDsl || createHeaderDsl() + const nextFooterDsl = footerDsl || splitDsl.footerDsl || createFooterDsl() + if (storage) { + storage.setItem(HEADER_STORAGE_KEY, JSON.stringify(serializeDsl(nextHeaderDsl))) + storage.setItem(FOOTER_STORAGE_KEY, JSON.stringify(serializeDsl(nextFooterDsl))) + } + + return { + headerDsl: nextHeaderDsl, + footerDsl: nextFooterDsl, + persisted: Boolean(storage), + messageKey: '4c11e15d.ebf4e4' + } +} + +export default { + loadHomeDecoration, + saveHomeDecoration, + loadHeaderFooterDecoration, + saveHeaderFooterDecoration +} diff --git a/src/components/sp-web-decoration/components/ConfigPanel.vue b/src/components/sp-web-decoration/components/ConfigPanel.vue new file mode 100644 index 0000000..c5121e8 --- /dev/null +++ b/src/components/sp-web-decoration/components/ConfigPanel.vue @@ -0,0 +1,175 @@ + + + diff --git a/src/components/sp-web-decoration/components/PreviewCanvas.vue b/src/components/sp-web-decoration/components/PreviewCanvas.vue new file mode 100644 index 0000000..601b468 --- /dev/null +++ b/src/components/sp-web-decoration/components/PreviewCanvas.vue @@ -0,0 +1,546 @@ +