diff --git a/.superpowers/brainstorm/472-1787800684/content/mine-records-comparison.html b/.superpowers/brainstorm/472-1787800684/content/mine-records-comparison.html new file mode 100644 index 0000000..632628a --- /dev/null +++ b/.superpowers/brainstorm/472-1787800684/content/mine-records-comparison.html @@ -0,0 +1,183 @@ + + + + + + 我的页面|用车需求与历史浏览收敛对比稿 + + + +
+
+
+
MINE · INFORMATION ARCHITECTURE
+

“用车需求 + 历史浏览”收敛方式对比

+

基于你提供的“我的”页面截图,保留现有视觉基线,只比较记录区的组织方式。

+
+
当前基线“继续探索 / 最近浏览”独立显示;“用车需求”若作为独立入口,会继续增加纵向区块。以下三种方案都把两者放入同一信息层级。
+
+ +
+ + +
+

B · 我的记录 + 筛选

+

统一成一张记录列表,用“全部 / 用车需求 / 浏览记录”筛选。信息更规整,但需要增加筛选交互。

+
+
+
11:14100% ▰
+
把旅程交给懂贵州的人
A QUIETER WAY TO TRAVEL
+
♙
万趣会员
已绑定手机号 185****2260
已登录
+

我的记录 2 条记录

+

按类型查看你的出行足迹

+
+
+
⌁
用车需求
5座舒适用车 · 2026-08-26
›
+
◈
亲子玩水
玩法路线 · 最近浏览
›
+
+ +
+
+
+
+ +
+

C · 两个快捷入口 + 浏览列表

+

合并成“我的旅程”模块,顶部放两个入口,下面保留历史浏览列表。入口识别快,但占用横向空间。

+
+
+
11:14100% ▰
+
把旅程交给懂贵州的人
A QUIETER WAY TO TRAVEL
+
♙
万趣会员
已绑定手机号 185****2260
已登录
+

我的旅程 1 条浏览

+

快速处理需求,继续探索内容

+
+
◈
亲子玩水
玩法路线 · 最近浏览
›
+ +
+
+
+
+
+
保持现有页面风格仅调整记录区不改变底部导航与会员卡
+
+ + + + diff --git a/.superpowers/brainstorm/472-1787800684/state/server-info b/.superpowers/brainstorm/472-1787800684/state/server-info new file mode 100644 index 0000000..43d94d0 --- /dev/null +++ b/.superpowers/brainstorm/472-1787800684/state/server-info @@ -0,0 +1 @@ +{"type":"server-started","port":58928,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:58928","screen_dir":"D:\\www\\znkj\\WonderQ-Project\\.superpowers\\brainstorm\\472-1787800684\\content","state_dir":"D:\\www\\znkj\\WonderQ-Project\\.superpowers\\brainstorm\\472-1787800684\\state"} diff --git a/.superpowers/brainstorm/472-1787800684/state/server.pid b/.superpowers/brainstorm/472-1787800684/state/server.pid new file mode 100644 index 0000000..ff5670f --- /dev/null +++ b/.superpowers/brainstorm/472-1787800684/state/server.pid @@ -0,0 +1 @@ +481 diff --git a/.superpowers/brainstorm/mine-records-comparison/index.html b/.superpowers/brainstorm/mine-records-comparison/index.html new file mode 100644 index 0000000..89baf0f --- /dev/null +++ b/.superpowers/brainstorm/mine-records-comparison/index.html @@ -0,0 +1,181 @@ + + + + + + 我的页面|用车需求与历史浏览收敛对比稿 + + + +
+
+
+
MINE · INFORMATION ARCHITECTURE
+

“用车需求 + 历史浏览”收敛方式对比

+

基于你提供的“我的”页面截图,保留现有视觉基线,只比较记录区的组织方式。

+
+
当前基线“继续探索 / 最近浏览”独立显示;“用车需求”若作为独立入口,会继续增加纵向区块。以下三种方案都把两者放入同一信息层级。
+
+ +
+ + +
+

B · 我的记录 + 筛选

+

统一成一张记录列表,用“全部 / 用车需求 / 浏览记录”筛选。信息更规整,但需要增加筛选交互。

+
+
+
11:14100% ▰
+
把旅程交给懂贵州的人
A QUIETER WAY TO TRAVEL
+
♙
万趣会员
已绑定手机号 185****2260
已登录
+

我的记录 2 条记录

+

按类型查看你的出行足迹

+
+
+
⌁
用车需求
5座舒适用车 · 2026-08-26
›
+
◈
亲子玩水
玩法路线 · 最近浏览
›
+
+ +
+
+
+
+ +
+

C · 两个快捷入口 + 浏览列表

+

合并成“我的旅程”模块,顶部放两个入口,下面保留历史浏览列表。入口识别快,但占用横向空间。

+
+
+
11:14100% ▰
+
把旅程交给懂贵州的人
A QUIETER WAY TO TRAVEL
+
♙
万趣会员
已绑定手机号 185****2260
已登录
+

我的旅程 1 条浏览

+

快速处理需求,继续探索内容

+
+
◈
亲子玩水
玩法路线 · 最近浏览
›
+ +
+
+
+
+
+
保持现有页面风格仅调整记录区不改变底部导航与会员卡
+
+ + diff --git a/WonderQ-Admin-UI-Vue/src/components/wanfa/ImageGalleryEditor.test.ts b/WonderQ-Admin-UI-Vue/src/components/wanfa/ImageGalleryEditor.test.ts new file mode 100644 index 0000000..b68a30d --- /dev/null +++ b/WonderQ-Admin-UI-Vue/src/components/wanfa/ImageGalleryEditor.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const stylesPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../styles.css"); + +describe("ImageGalleryEditor layout", () => { + it("uses the full editor width for each detail image", () => { + const styles = readFileSync(stylesPath, "utf8"); + const galleryGrid = styles.match(/\.gallery-grid\s*\{([\s\S]*?)\}/)?.[1] ?? ""; + + expect(galleryGrid).toMatch(/grid-template-columns:\s*minmax\(0,\s*1fr\);/); + }); +}); diff --git a/WonderQ-Admin-UI-Vue/src/styles.css b/WonderQ-Admin-UI-Vue/src/styles.css index 3dfa11a..c77bdcd 100644 --- a/WonderQ-Admin-UI-Vue/src/styles.css +++ b/WonderQ-Admin-UI-Vue/src/styles.css @@ -10,199 +10,1046 @@ --surface: #ffffff; } -* { box-sizing: border-box; } -body { margin: 0; min-width: 320px; background: #f5f7fb; } -button, input { font: inherit; } -.brand-mark { display: grid; place-items: center; width: 35px; height: 35px; border-radius: 10px; color: #fff; background: #1d4ed8; font-size: 11px; font-weight: 800; letter-spacing: .08em; } -.eyebrow { margin: 0 0 7px; color: #2563eb; font-size: 10px; font-weight: 800; letter-spacing: .16em; } -.page-intro h2 { margin: 0; color: var(--ink); font-size: 23px; line-height: 1.2; } -.page-stack { max-width: 1220px; margin: 0 auto; } -.page-intro { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-bottom: 26px; } -.page-intro-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } -.page-intro p:not(.eyebrow) { margin: 9px 0 0; color: var(--muted); font-size: 13px; } -.metric-grid, .module-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 20px; } -.metric-card, .module-card, .panel-card, .advisor-card { border: 1px solid var(--line); border-radius: 12px; } -.metric-card { position: relative; min-height: 145px; overflow: hidden; } -.metric-card::after { content: ""; position: absolute; right: -28px; bottom: -46px; width: 130px; height: 130px; border-radius: 50%; background: #eff6ff; } -.metric-card.accent::after { background: #ecfdf5; } -.metric-card span, .metric-card small, .module-card small { display: block; color: var(--muted); font-size: 12px; } -.metric-card strong { display: block; margin: 14px 0 5px; color: var(--ink); font-size: 34px; line-height: 1; } -.metric-card small { color: #94a3b8; } -.panel-heading { display: flex; justify-content: space-between; align-items: center; font-weight: 650; } -.loading-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } -.module-card { display: flex; flex-direction: column; gap: 8px; min-height: 140px; } -.module-label { color: #475569; font-weight: 650; } -.module-card strong { font-size: 29px; } -.module-card .el-button { align-self: flex-start; padding-left: 0; } -.category-list { display: grid; gap: 16px; } -.advisor-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } -.advisor-card { display: flex; align-items: center; gap: 12px; } -.advisor-card > div:nth-child(2) { flex: 1; } -.advisor-card strong, .advisor-card span { display: block; } -.advisor-card span { margin-top: 4px; color: var(--muted); font-size: 12px; } -.advisor-avatar { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; color: #1d4ed8; background: #dbeafe; font-weight: 700; } -.concierge-list { display: grid; gap: 10px; } -.concierge-row { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 13px 0; border-bottom: 1px solid #f1f5f9; } -.concierge-row:last-child { border-bottom: 0; } -.concierge-summary, .concierge-actions, .media-toolbar, .single-media-heading, .subsection-heading { display: flex; align-items: center; gap: 12px; } -.concierge-summary { min-width: 0; } -.concierge-summary > div:last-child { min-width: 0; } -.concierge-summary strong, .concierge-summary span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.concierge-summary span, .subsection-heading span { margin-top: 4px; color: var(--muted); font-size: 12px; } -.concierge-actions { flex-wrap: wrap; justify-content: flex-end; } -.concierge-editor, .single-media-editor { padding: 4px 4px 14px; } -.concierge-detail-editor { margin: 20px 0; padding-top: 20px; border-top: 1px solid var(--line); } -.subsection-heading { justify-content: space-between; margin-bottom: 12px; } -.subsection-heading > div { display: grid; gap: 2px; } -.concierge-detail-row { display: grid; grid-template-columns: 150px minmax(0, 1fr) auto; gap: 8px; margin-bottom: 8px; } -.single-media-editor { display: grid; align-content: start; gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 9px; } -.single-media-heading { justify-content: space-between; } -.single-media-preview { width: 100%; height: 150px; border-radius: 8px; background: #f1f5f9; } -.lead-filter-card { margin-bottom: 16px; } -.lead-filters { display: grid; grid-template-columns: 170px 170px minmax(140px, 1fr) 60px 60px; gap: 10px; } -.lead-filters > .el-button { width: 60px; } -.lead-pagination { display: flex; justify-content: flex-end; align-items: center; gap: 14px; padding-top: 16px; color: var(--muted); font-size: 12px; } -.lead-summary { display: grid; gap: 4px; min-width: 0; } -.lead-summary span { overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } -.lead-detail-dialog .el-dialog__body { padding-bottom: 16px; } -.lead-detail-dialog .el-dialog__footer { padding-top: 0; } -.lead-detail-card { overflow: hidden; margin-top: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); } -.lead-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; background: var(--line); } -.lead-detail-item { min-width: 0; padding: 15px 16px; background: var(--surface); } -.lead-detail-grid dt { color: var(--muted); font-size: 12px; line-height: 1.4; } -.lead-detail-grid dd { min-width: 0; margin: 5px 0 0; overflow-wrap: anywhere; color: var(--ink); font-weight: 650; line-height: 1.45; } -.lead-detail-section { margin-top: 16px; padding: 14px 16px; border: 1px solid transparent; border-radius: 9px; background: #f8fafc; } -.lead-detail-section-title { display: flex; align-items: center; gap: 8px; color: #475569; font-size: 13px; line-height: 1.4; } -.lead-detail-section-title::before { width: 3px; height: 14px; border-radius: 3px; background: var(--blue); content: ""; } -.lead-detail-section p { margin: 7px 0 0; overflow-wrap: anywhere; color: #475569; line-height: 1.6; } -.lead-detail-note { border-color: #e4eaf2; background: #f5f8fc; } -.lead-detail-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-top: 14px; border-top: 1px solid var(--line); } -.lead-detail-footer-status, .lead-detail-footer-actions { display: flex; align-items: center; gap: 10px; } -.lead-detail-footer-status { color: var(--muted); font-size: 12px; } -.lead-detail-footer-actions .el-select { width: 190px; } -.media-toolbar { justify-content: flex-end; } -.media-toolbar .el-input { width: 150px; } -.media-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } -.media-card { overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: #fff; } -.media-card > .el-image { display: block; width: 100%; height: 150px; background: #f1f5f9; } -.media-card-content { display: grid; gap: 5px; padding: 10px; } -.media-card-content span, .media-card-content small { overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } -.media-card-content small { color: #94a3b8; } -.operations-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 18px; } -.operation-card h3 { margin: 0; font-size: 19px; } -.operation-card > p:not(.eyebrow) { min-height: 50px; margin: 10px 0 20px; color: var(--muted); line-height: 1.7; } -.danger-operation { border-color: #fecaca; } -.operation-result { margin-top: 18px; } -.json-preview { max-height: 220px; overflow: auto; margin: 0; color: #475569; font-size: 12px; white-space: pre-wrap; } -.site-config-intro { align-items: center; } -.site-config-intro + .el-alert { margin-bottom: 18px; } -.site-config-tabs { margin-top: 18px; } -.site-config-panel { padding: 4px 0 0; } -.site-config-panel .panel-heading { margin-bottom: 18px; } -.site-config-panel .panel-heading > div { display: grid; gap: 5px; } -.site-config-panel .panel-heading span { color: var(--muted); font-size: 12px; font-weight: 400; } -.site-item-content { display: flex; align-items: center; gap: 12px; min-width: 0; } -.site-item-content > div { min-width: 0; } -.site-item-content strong, .site-item-content small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.site-item-content small { margin-top: 5px; color: var(--muted); font-size: 12px; } -.site-item-thumb { flex: 0 0 auto; width: 56px; height: 40px; border-radius: 7px; background: #f1f5f9; } -.site-config-editor { padding: 4px 4px 14px; } -.wanfa-workspace { display: grid; grid-template-columns: minmax(260px, .34fr) minmax(0, .66fr); gap: 18px; align-items: start; } -.wanfa-category-panel, .wanfa-route-panel { min-width: 0; } -.wanfa-category-list { display: grid; gap: 8px; } -.wanfa-category-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 7px; padding: 10px; border: 1px solid transparent; border-radius: 9px; background: #f8fafc; } -.wanfa-category-row.is-selected { border-color: #bfdbfe; background: #eff6ff; } -.wanfa-category-select { display: flex; justify-content: space-between; gap: 8px; width: 100%; padding: 0; border: 0; color: var(--ink); background: transparent; text-align: left; cursor: pointer; } -.wanfa-category-select span { color: var(--muted); font-size: 12px; white-space: nowrap; } -.wanfa-row-actions { display: flex; flex-wrap: wrap; gap: 2px; } -.wanfa-row-actions .el-button { margin-left: 0; } -.wanfa-editor { padding: 4px 4px 14px; } -.route-editor { padding-bottom: 0; } -.detail-fields { margin-top: 22px; padding-top: 20px; border-top: 1px solid var(--line); } -.detail-fields-heading { display: grid; gap: 4px; margin-bottom: 18px; } -.detail-fields-heading span { color: var(--muted); font-size: 12px; line-height: 1.5; } -.route-editor .el-alert { margin-top: 18px; } -.gallery-editor { display: grid; gap: 12px; margin-bottom: 18px; padding-top: 4px; } -.detail-fields .gallery-editor { width: 100%; margin-top: 20px; } -.gallery-editor-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; } -.gallery-editor-heading > div { display: grid; gap: 4px; } -.gallery-editor-heading span { color: var(--muted); font-size: 12px; } -.media-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } -.gallery-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } -.gallery-item { min-width: 0; padding: 8px; border: 1px solid var(--line); border-radius: 9px; background: #fff; } -.gallery-item .el-image { display: block; width: 100%; height: 120px; border-radius: 6px; background: #f1f5f9; } -.gallery-item small { display: block; overflow: hidden; margin-top: 7px; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } -.gallery-actions { display: flex; gap: 2px; margin-top: 3px; } -.editor-heading { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 24px; } -.editor-heading h3 { margin: 0; font-size: 21px; } -.editor-heading p:not(.eyebrow) { margin: 7px 0 0; color: var(--muted); font-size: 13px; line-height: 1.6; } -.site-config-editor .el-form-item { margin-bottom: 18px; } -.field-hint { margin: 6px 0 0; color: #94a3b8; font-size: 12px; line-height: 1.5; } -.site-image-preview { display: block; width: 100%; max-width: 260px; height: 132px; margin-top: 12px; border-radius: 9px; background: #f1f5f9; } -.editor-options { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } -.editor-sort-item { margin-top: 20px; } -.editor-actions { display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 10px; margin-top: 0; padding: 14px 8px calc(4px + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: var(--surface); } -.editor-drawer .el-drawer__body { display: flex; min-height: 0; flex-direction: column; padding: 0; overflow: hidden; } -.drawer-editor-form { display: flex; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; padding: 4px 4px 0; } -.drawer-editor-form__content { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 0 8px 20px; scrollbar-gutter: stable; } -.settings-drawer .el-drawer__body { display: flex; min-height: 0; flex-direction: column; } -.settings-drawer .settings-form { min-height: 0; flex: 1 1 auto; overflow-y: auto; } -.settings-drawer .el-drawer__footer { flex: 0 0 auto; } -.home-content-panel .panel-heading { margin-bottom: 18px; } -.home-content-panel .panel-heading > div { display: flex; align-items: baseline; gap: 10px; } -.home-content-panel .panel-heading span { color: var(--muted); font-size: 12px; font-weight: 400; } -.home-content-options { margin-top: 20px; } -.system-action-bar .el-button:hover { color: #fff; } +* { + box-sizing: border-box; +} +body { + margin: 0; + min-width: 320px; + background: #f5f7fb; +} +button, +input { + font: inherit; +} +.brand-mark { + display: grid; + place-items: center; + width: 35px; + height: 35px; + border-radius: 10px; + color: #fff; + background: #1d4ed8; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; +} +.eyebrow { + margin: 0 0 7px; + color: #2563eb; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.16em; +} +.page-intro h2 { + margin: 0; + color: var(--ink); + font-size: 23px; + line-height: 1.2; +} +.page-stack { + max-width: 1220px; + margin: 0 auto; +} +.page-intro { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 24px; + margin-bottom: 26px; +} +.page-intro-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} +.page-intro p:not(.eyebrow) { + margin: 9px 0 0; + color: var(--muted); + font-size: 13px; +} +.metric-grid, +.module-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} +.metric-card, +.module-card, +.panel-card, +.advisor-card { + border: 1px solid var(--line); + border-radius: 12px; +} +.metric-card { + position: relative; + min-height: 145px; + overflow: hidden; +} +.metric-card::after { + content: ""; + position: absolute; + right: -28px; + bottom: -46px; + width: 130px; + height: 130px; + border-radius: 50%; + background: #eff6ff; +} +.metric-card.accent::after { + background: #ecfdf5; +} +.metric-card span, +.metric-card small, +.module-card small { + display: block; + color: var(--muted); + font-size: 12px; +} +.metric-card strong { + display: block; + margin: 14px 0 5px; + color: var(--ink); + font-size: 34px; + line-height: 1; +} +.metric-card small { + color: #94a3b8; +} +.panel-heading { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 650; +} +.loading-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.module-card { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 140px; +} +.module-label { + color: #475569; + font-weight: 650; +} +.module-card strong { + font-size: 29px; +} +.module-card .el-button { + align-self: flex-start; + padding-left: 0; +} +.category-list { + display: grid; + gap: 16px; +} +.advisor-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} +.advisor-card { + display: flex; + align-items: center; + gap: 12px; +} +.advisor-card > div:nth-child(2) { + flex: 1; +} +.advisor-card strong, +.advisor-card span { + display: block; +} +.advisor-card span { + margin-top: 4px; + color: var(--muted); + font-size: 12px; +} +.advisor-avatar { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 50%; + color: #1d4ed8; + background: #dbeafe; + font-weight: 700; +} +.concierge-list { + display: grid; + gap: 10px; +} +.concierge-row { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + padding: 13px 0; + border-bottom: 1px solid #f1f5f9; +} +.concierge-row:last-child { + border-bottom: 0; +} +.concierge-summary, +.concierge-actions, +.media-toolbar, +.single-media-heading, +.subsection-heading { + display: flex; + align-items: center; + gap: 12px; +} +.concierge-summary { + min-width: 0; +} +.concierge-summary > div:last-child { + min-width: 0; +} +.concierge-summary strong, +.concierge-summary span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.concierge-summary span, +.subsection-heading span { + margin-top: 4px; + color: var(--muted); + font-size: 12px; +} +.concierge-actions { + flex-wrap: wrap; + justify-content: flex-end; +} +.concierge-editor, +.single-media-editor { + padding: 4px 4px 14px; +} +.concierge-detail-editor { + margin: 20px 0; + padding-top: 20px; + border-top: 1px solid var(--line); +} +.subsection-heading { + justify-content: space-between; + margin-bottom: 12px; +} +.subsection-heading > div { + display: grid; + gap: 2px; +} +.concierge-detail-row { + display: grid; + grid-template-columns: 150px minmax(0, 1fr) auto; + gap: 8px; + margin-bottom: 8px; +} +.single-media-editor { + display: grid; + align-content: start; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 9px; +} +.single-media-heading { + justify-content: space-between; +} +.single-media-preview { + width: 100%; + height: 150px; + border-radius: 8px; + background: #f1f5f9; +} +.lead-filter-card { + margin-bottom: 16px; +} +.lead-filters { + display: grid; + grid-template-columns: 170px 170px minmax(140px, 1fr) 60px 60px; + gap: 10px; +} +.lead-filters > .el-button { + width: 60px; +} +.lead-pagination { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 14px; + padding-top: 16px; + color: var(--muted); + font-size: 12px; +} +.lead-summary { + display: grid; + gap: 4px; + min-width: 0; +} +.lead-summary span { + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.lead-detail-dialog .el-dialog__body { + padding-bottom: 16px; +} +.lead-detail-dialog .el-dialog__footer { + padding-top: 0; +} +.lead-detail-card { + overflow: hidden; + margin-top: 0; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); +} +.lead-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + margin: 0; + background: var(--line); +} +.lead-detail-item { + min-width: 0; + padding: 15px 16px; + background: var(--surface); +} +.lead-detail-grid dt { + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} +.lead-detail-grid dd { + min-width: 0; + margin: 5px 0 0; + overflow-wrap: anywhere; + color: var(--ink); + font-weight: 650; + line-height: 1.45; +} +.lead-detail-section { + margin-top: 16px; + padding: 14px 16px; + border: 1px solid transparent; + border-radius: 9px; + background: #f8fafc; +} +.lead-detail-section-title { + display: flex; + align-items: center; + gap: 8px; + color: #475569; + font-size: 13px; + line-height: 1.4; +} +.lead-detail-section-title::before { + width: 3px; + height: 14px; + border-radius: 3px; + background: var(--blue); + content: ""; +} +.lead-detail-section p { + margin: 7px 0 0; + overflow-wrap: anywhere; + color: #475569; + line-height: 1.6; +} +.lead-detail-note { + border-color: #e4eaf2; + background: #f5f8fc; +} +.lead-detail-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 14px; + border-top: 1px solid var(--line); +} +.lead-detail-footer-status, +.lead-detail-footer-actions { + display: flex; + align-items: center; + gap: 10px; +} +.lead-detail-footer-status { + color: var(--muted); + font-size: 12px; +} +.lead-detail-footer-actions .el-select { + width: 190px; +} +.media-toolbar { + justify-content: flex-end; +} +.media-toolbar .el-input { + width: 150px; +} +.media-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} +.media-card { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; +} +.media-card > .el-image { + display: block; + width: 100%; + height: 150px; + background: #f1f5f9; +} +.media-card-content { + display: grid; + gap: 5px; + padding: 10px; +} +.media-card-content span, +.media-card-content small { + overflow: hidden; + color: var(--muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.media-card-content small { + color: #94a3b8; +} +.operations-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 18px; +} +.operation-card h3 { + margin: 0; + font-size: 19px; +} +.operation-card > p:not(.eyebrow) { + min-height: 50px; + margin: 10px 0 20px; + color: var(--muted); + line-height: 1.7; +} +.danger-operation { + border-color: #fecaca; +} +.operation-result { + margin-top: 18px; +} +.json-preview { + max-height: 220px; + overflow: auto; + margin: 0; + color: #475569; + font-size: 12px; + white-space: pre-wrap; +} +.site-config-intro { + align-items: center; +} +.site-config-intro + .el-alert { + margin-bottom: 18px; +} +.site-config-tabs { + margin-top: 18px; +} +.site-config-panel { + padding: 4px 0 0; +} +.site-config-panel .panel-heading { + margin-bottom: 18px; +} +.site-config-panel .panel-heading > div { + display: grid; + gap: 5px; +} +.site-config-panel .panel-heading span { + color: var(--muted); + font-size: 12px; + font-weight: 400; +} +.site-item-content { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.site-item-content > div { + min-width: 0; +} +.site-item-content strong, +.site-item-content small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.site-item-content small { + margin-top: 5px; + color: var(--muted); + font-size: 12px; +} +.site-item-thumb { + flex: 0 0 auto; + width: 56px; + height: 40px; + border-radius: 7px; + background: #f1f5f9; +} +.site-config-editor { + padding: 4px 4px 14px; +} +.wanfa-workspace { + display: grid; + grid-template-columns: minmax(260px, 0.34fr) minmax(0, 0.66fr); + gap: 18px; + align-items: start; +} +.wanfa-category-panel, +.wanfa-route-panel { + min-width: 0; +} +.wanfa-category-list { + display: grid; + gap: 8px; +} +.wanfa-category-row { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 7px; + padding: 10px; + border: 1px solid transparent; + border-radius: 9px; + background: #f8fafc; +} +.wanfa-category-row.is-selected { + border-color: #bfdbfe; + background: #eff6ff; +} +.wanfa-category-select { + display: flex; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 0; + border: 0; + color: var(--ink); + background: transparent; + text-align: left; + cursor: pointer; +} +.wanfa-category-select span { + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} +.wanfa-row-actions { + display: flex; + flex-wrap: wrap; + gap: 2px; +} +.wanfa-row-actions .el-button { + margin-left: 0; +} +.wanfa-editor { + padding: 4px 4px 14px; +} +.route-editor { + padding-bottom: 0; +} +.detail-fields { + margin-top: 22px; + padding-top: 20px; + border-top: 1px solid var(--line); +} +.detail-fields-heading { + display: grid; + gap: 4px; + margin-bottom: 18px; +} +.detail-fields-heading span { + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} +.route-editor .el-alert { + margin-top: 18px; +} +.gallery-editor { + display: grid; + gap: 12px; + margin-bottom: 18px; + padding-top: 4px; +} +.detail-fields .gallery-editor { + width: 100%; + margin-top: 20px; +} +.gallery-editor-heading { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} +.gallery-editor-heading > div { + display: grid; + gap: 4px; +} +.gallery-editor-heading span { + color: var(--muted); + font-size: 12px; +} +.media-file-input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} +.gallery-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} +.gallery-item { + min-width: 0; + padding: 8px; + border: 1px solid var(--line); + border-radius: 9px; + background: #fff; +} +.gallery-item .el-image { + display: block; + width: 100%; + height: 120px; + border-radius: 6px; + background: #f1f5f9; +} +.gallery-item small { + display: block; + overflow: hidden; + margin-top: 7px; + color: var(--muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.gallery-actions { + display: flex; + gap: 2px; + margin-top: 3px; +} +.editor-heading { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 24px; +} +.editor-heading h3 { + margin: 0; + font-size: 21px; +} +.editor-heading p:not(.eyebrow) { + margin: 7px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} +.site-config-editor .el-form-item { + margin-bottom: 18px; +} +.field-hint { + margin: 6px 0 0; + color: #94a3b8; + font-size: 12px; + line-height: 1.5; +} +.site-image-preview { + display: block; + width: 100%; + max-width: 260px; + height: 132px; + margin-top: 12px; + border-radius: 9px; + background: #f1f5f9; +} +.editor-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.editor-sort-item { + margin-top: 20px; +} +.editor-actions { + display: flex; + flex: 0 0 auto; + justify-content: flex-end; + gap: 10px; + margin-top: 0; + padding: 14px 8px; + border-top: 1px solid var(--line); + background: var(--surface); +} +.editor-drawer .el-drawer__body { + display: flex; + min-height: 0; + flex-direction: column; + padding: 0; + overflow: hidden; +} +.drawer-editor-form { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + overflow: hidden; + padding: 4px 4px 0; +} +.drawer-editor-form__content { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + padding: 0 8px 20px; + scrollbar-gutter: stable; +} +.settings-drawer .el-drawer__body { + display: flex; + min-height: 0; + flex-direction: column; +} +.settings-drawer .settings-form { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; +} +.settings-drawer .el-drawer__footer { + flex: 0 0 auto; +} +.home-content-panel .panel-heading { + margin-bottom: 18px; +} +.home-content-panel .panel-heading > div { + display: flex; + align-items: baseline; + gap: 10px; +} +.home-content-panel .panel-heading span { + color: var(--muted); + font-size: 12px; + font-weight: 400; +} +.home-content-options { + margin-top: 20px; +} +.system-action-bar .el-button:hover { + color: #fff; +} /* RuoYi-style system management pages: compact filters, bordered tables and explicit RBAC hierarchy. */ -.system-resource-page .page-intro { margin-bottom: 18px; } -.system-filter-card, .system-list-card { margin-bottom: 16px; border-radius: 4px; } -.system-filter-card .el-card__body { padding: 16px 18px 2px; } -.system-filter-card .el-form { display: flex; flex-wrap: wrap; align-items: center; gap: 0 16px; } -.system-filter-card .el-form-item { margin-right: 0; margin-bottom: 14px; } -.system-filter-card .el-input { width: 230px; } -.system-filter-card .el-form-item:last-child { margin-left: auto; } -.system-list-card .el-card__body { padding: 0; } -.system-table-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 18px; border-bottom: 1px solid var(--line); } -.system-table-heading > div:first-child { display: flex; align-items: baseline; gap: 10px; } -.system-table-heading strong { color: #303133; font-size: 14px; } -.system-table-heading span { color: #909399; font-size: 12px; } -.system-action-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; } -.system-action-bar .el-button { margin-left: 0; } -.system-menu-name, .system-tree-label { display: inline-flex; align-items: center; gap: 8px; min-height: 24px; } -.system-menu-name .layout-icon { flex: 0 0 auto; } -.system-list-card .el-table th.el-table__cell { color: #606266; font-weight: 600; background: #f5f7fa; } -.system-list-card .el-table td.el-table__cell, .system-list-card .el-table th.el-table__cell { border-bottom-color: #ebeef5; } -.system-list-card .el-table .el-button + .el-button { margin-left: 8px; } -.system-editor-dialog .el-dialog__body { padding-top: 8px; } -.system-editor-form .el-form-item { margin-bottom: 18px; } -.system-editor-form .full-width { width: 100%; } -.system-tree-panel { width: 100%; overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: #fff; } -.system-tree-panel__heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; color: #909399; font-size: 12px; border-bottom: 1px solid var(--line); background: #f8fafc; } -.system-tree-panel .el-tree { max-height: 260px; overflow: auto; padding: 8px 12px; } -.system-tree-panel .el-tree-node__content { height: 32px; } -.system-editor-form .el-radio-group { display: flex; gap: 18px; } -.menu-icon-picker { display: flex; flex-direction: column; gap: 8px; } -.menu-icon-picker__search { width: 100%; } -.menu-icon-picker__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; max-height: 300px; overflow-y: auto; padding-right: 2px; } -.menu-icon-picker__option { display: inline-flex; min-height: 58px; align-items: center; justify-content: center; flex-direction: column; gap: 4px; border: 1px solid transparent; border-radius: 4px; color: #606266; background: transparent; cursor: pointer; font-family: inherit; font-size: 11px; } -.menu-icon-picker__option:hover, .menu-icon-picker__option:focus-visible, .menu-icon-picker__option.is-active { color: var(--primary-color, #409eff); border-color: #b3d8ff; background: #ecf5ff; outline: none; } -.menu-icon-picker .el-empty { padding: 8px 0; } -.system-editor-form .el-input-number { width: 100%; } +.system-resource-page .page-intro { + margin-bottom: 18px; +} +.system-filter-card, +.system-list-card { + margin-bottom: 16px; + border-radius: 4px; +} +.system-filter-card .el-card__body { + padding: 16px 18px 2px; +} +.system-filter-card .el-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 16px; +} +.system-filter-card .el-form-item { + margin-right: 0; + margin-bottom: 14px; +} +.system-filter-card .el-input { + width: 230px; +} +.system-filter-card .el-form-item:last-child { + margin-left: auto; +} +.system-list-card .el-card__body { + padding: 0; +} +.system-table-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 15px 18px; + border-bottom: 1px solid var(--line); +} +.system-table-heading > div:first-child { + display: flex; + align-items: baseline; + gap: 10px; +} +.system-table-heading strong { + color: #303133; + font-size: 14px; +} +.system-table-heading span { + color: #909399; + font-size: 12px; +} +.system-action-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} +.system-action-bar .el-button { + margin-left: 0; +} +.system-menu-name, +.system-tree-label { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 24px; +} +.system-menu-name .layout-icon { + flex: 0 0 auto; +} +.system-list-card .el-table th.el-table__cell { + color: #606266; + font-weight: 600; + background: #f5f7fa; +} +.system-list-card .el-table td.el-table__cell, +.system-list-card .el-table th.el-table__cell { + border-bottom-color: #ebeef5; +} +.system-list-card .el-table .el-button + .el-button { + margin-left: 8px; +} +.system-editor-dialog .el-dialog__body { + padding-top: 8px; +} +.system-editor-form .el-form-item { + margin-bottom: 18px; +} +.system-editor-form .full-width { + width: 100%; +} +.system-tree-panel { + width: 100%; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 4px; + background: #fff; +} +.system-tree-panel__heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + color: #909399; + font-size: 12px; + border-bottom: 1px solid var(--line); + background: #f8fafc; +} +.system-tree-panel .el-tree { + max-height: 260px; + overflow: auto; + padding: 8px 12px; +} +.system-tree-panel .el-tree-node__content { + height: 32px; +} +.system-editor-form .el-radio-group { + display: flex; + gap: 18px; +} +.menu-icon-picker { + display: flex; + flex-direction: column; + gap: 8px; +} +.menu-icon-picker__search { + width: 100%; +} +.menu-icon-picker__grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + max-height: 300px; + overflow-y: auto; + padding-right: 2px; +} +.menu-icon-picker__option { + display: inline-flex; + min-height: 58px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 4px; + border: 1px solid transparent; + border-radius: 4px; + color: #606266; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 11px; +} +.menu-icon-picker__option:hover, +.menu-icon-picker__option:focus-visible, +.menu-icon-picker__option.is-active { + color: var(--primary-color, #409eff); + border-color: #b3d8ff; + background: #ecf5ff; + outline: none; +} +.menu-icon-picker .el-empty { + padding: 8px 0; +} +.system-editor-form .el-input-number { + width: 100%; +} @media (max-width: 760px) { - .system-table-heading { align-items: flex-start; flex-direction: column; } - .system-action-bar { justify-content: flex-start; width: 100%; } - .system-filter-card .el-form-item:last-child { margin-left: 0; } - .system-filter-card .el-input { width: min(230px, 100%); } + .system-table-heading { + align-items: flex-start; + flex-direction: column; + } + .system-action-bar { + justify-content: flex-start; + width: 100%; + } + .system-filter-card .el-form-item:last-child { + margin-left: 0; + } + .system-filter-card .el-input { + width: min(230px, 100%); + } } @media (max-width: 640px) { - .system-editor-form .el-row { row-gap: 0; } - .system-editor-form .el-col-12 { width: 100%; max-width: 100%; flex: 0 0 100%; } + .system-editor-form .el-row { + row-gap: 0; + } + .system-editor-form .el-col-12 { + width: 100%; + max-width: 100%; + flex: 0 0 100%; + } +} +@media (max-width: 880px) { + .wanfa-workspace { + grid-template-columns: 1fr; + } +} +@media (max-width: 980px) { + .media-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 760px) { + .concierge-row { + align-items: flex-start; + flex-direction: column; + } + .concierge-actions { + justify-content: flex-start; + } + .lead-filters { + grid-template-columns: 1fr 1fr; + } + .lead-filters .el-input { + grid-column: 1 / -1; + } + .operations-grid { + grid-template-columns: 1fr; + } + .media-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 640px) { + .site-config-intro { + align-items: flex-start; + } + .site-config-intro .el-button, + .page-intro-actions { + width: 100%; + } + .page-intro-actions { + justify-content: stretch; + } + .page-intro-actions .el-button { + flex: 1; + } + .editor-options { + grid-template-columns: 1fr; + gap: 0; + } + .site-config-panel .panel-heading { + align-items: flex-start; + flex-direction: column; + } + .site-config-panel .panel-heading .el-button { + width: 100%; + } + .concierge-detail-row { + grid-template-columns: 1fr; + } + .lead-filters { + grid-template-columns: 1fr; + } + .lead-filters .el-input { + grid-column: auto; + } + .lead-detail-grid { + grid-template-columns: 1fr; + } + .lead-detail-footer { + align-items: flex-start; + flex-direction: column; + } + .lead-detail-footer-actions { + width: 100%; + } + .lead-detail-footer-actions .el-select { + min-width: 0; + flex: 1; + width: auto; + } + .media-toolbar { + align-items: stretch; + flex-direction: column; + width: 100%; + } + .media-toolbar .el-input { + width: 100%; + } + .media-grid { + grid-template-columns: 1fr; + } } -@media (max-width: 880px) { .wanfa-workspace { grid-template-columns: 1fr; } } -@media (max-width: 980px) { .media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } -@media (max-width: 760px) { .concierge-row { align-items: flex-start; flex-direction: column; } .concierge-actions { justify-content: flex-start; } .lead-filters { grid-template-columns: 1fr 1fr; } .lead-filters .el-input { grid-column: 1 / -1; } .operations-grid { grid-template-columns: 1fr; } .media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 640px) { .site-config-intro { align-items: flex-start; } .site-config-intro .el-button, .page-intro-actions { width: 100%; } .page-intro-actions { justify-content: stretch; } .page-intro-actions .el-button { flex: 1; } .editor-options { grid-template-columns: 1fr; gap: 0; } .site-config-panel .panel-heading { align-items: flex-start; flex-direction: column; } .site-config-panel .panel-heading .el-button { width: 100%; } .concierge-detail-row { grid-template-columns: 1fr; } .lead-filters { grid-template-columns: 1fr; } .lead-filters .el-input { grid-column: auto; } .lead-detail-grid { grid-template-columns: 1fr; } .lead-detail-footer { align-items: flex-start; flex-direction: column; } .lead-detail-footer-actions { width: 100%; } .lead-detail-footer-actions .el-select { min-width: 0; flex: 1; width: auto; } .media-toolbar { align-items: stretch; flex-direction: column; width: 100%; } .media-toolbar .el-input { width: 100%; } .media-grid { grid-template-columns: 1fr; } } /* RuoYi-compatible shell: fixed sidebar, compact navbar, tags view and responsive content. */ .admin-layout { @@ -238,13 +1085,24 @@ button, input { font: inherit; } flex-direction: column; overflow: hidden; border-right: 0; - box-shadow: 2px 0 6px rgba(0, 21, 41, .12); - transition: width 200ms ease, background-color 200ms ease; + box-shadow: 2px 0 6px rgba(0, 21, 41, 0.12); + transition: + width 200ms ease, + background-color 200ms ease; } -.sidebar-drawer--dark { color: #bfcbd9; } -.sidebar-drawer--light { color: #303133; border-right: 1px solid #e6e6e6; box-shadow: none; } -.sidebar-drawer--collapsed { width: var(--sidebar-collapsed-width) !important; min-width: var(--sidebar-collapsed-width) !important; } +.sidebar-drawer--dark { + color: #bfcbd9; +} +.sidebar-drawer--light { + color: #303133; + border-right: 1px solid #e6e6e6; + box-shadow: none; +} +.sidebar-drawer--collapsed { + width: var(--sidebar-collapsed-width) !important; + min-width: var(--sidebar-collapsed-width) !important; +} .sidebar-logo { display: flex; @@ -257,8 +1115,12 @@ button, input { font: inherit; } white-space: nowrap; } -.sidebar-logo--collapsed { justify-content: center; padding: 0; } -.sidebar-logo__mark, .brand-mark { +.sidebar-logo--collapsed { + justify-content: center; + padding: 0; +} +.sidebar-logo__mark, +.brand-mark { display: grid; width: 32px; height: 32px; @@ -269,29 +1131,92 @@ button, input { font: inherit; } background: #409eff; font-size: 11px; font-weight: 800; - letter-spacing: .08em; + letter-spacing: 0.08em; } -.sidebar-logo__copy { display: grid; gap: 2px; min-width: 0; } -.sidebar-logo__copy strong { color: #fff; font-size: 15px; font-weight: 600; } -.sidebar-logo__copy span { color: rgba(255,255,255,.6); font-size: 10px; } -.sidebar-drawer--light .sidebar-logo__copy strong { color: #303133; } -.sidebar-drawer--light .sidebar-logo__copy span { color: #909399; } +.sidebar-logo__copy { + display: grid; + gap: 2px; + min-width: 0; +} +.sidebar-logo__copy strong { + color: #fff; + font-size: 15px; + font-weight: 600; +} +.sidebar-logo__copy span { + color: rgba(255, 255, 255, 0.6); + font-size: 10px; +} +.sidebar-drawer--light .sidebar-logo__copy strong { + color: #303133; +} +.sidebar-drawer--light .sidebar-logo__copy span { + color: #909399; +} -.sidebar-scrollbar { flex: 1 1 auto; min-height: 0; } -.sidebar-menu { width: 100%; padding: 6px 0; border-right: 0; } -.sidebar-menu .el-menu-item, .sidebar-menu .el-sub-menu__title { height: 46px; line-height: 46px; margin: 0; border-radius: 0; } -.sidebar-menu .el-menu-item.is-active { color: #fff !important; background: var(--primary-color); } -.sidebar-drawer--light .sidebar-menu .el-menu-item.is-active { color: var(--primary-color) !important; background: #ecf5ff; } -.sidebar-menu .el-menu-item:hover, .sidebar-menu .el-sub-menu__title:hover { background: rgba(64,158,255,.16); } -.sidebar-menu .el-sub-menu.is-active > .el-sub-menu__title { color: #fff; } -.sidebar-drawer--light .sidebar-menu .el-sub-menu.is-active > .el-sub-menu__title { color: var(--primary-color); } -.sidebar-menu .el-menu--inline { background: transparent !important; } -.sidebar-item__content { display: inline-flex; width: 100%; align-items: center; gap: 12px; } -.sidebar-item__content svg { flex: 0 0 auto; } -.layout-icon { display: block; flex: 0 0 auto; vertical-align: middle; } -.sidebar-item__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.el-menu--collapse .sidebar-item__content { justify-content: center; } +.sidebar-scrollbar { + flex: 1 1 auto; + min-height: 0; +} +.sidebar-menu { + width: 100%; + padding: 6px 0; + border-right: 0; +} +.sidebar-menu .el-menu-item, +.sidebar-menu .el-sub-menu__title { + height: 46px; + line-height: 46px; + margin: 0; + border-radius: 0; +} +.sidebar-menu .el-menu-item.is-active { + color: #fff !important; + background: var(--primary-color); +} +.sidebar-drawer--light .sidebar-menu .el-menu-item.is-active { + color: var(--primary-color) !important; + background: #ecf5ff; +} +.sidebar-menu .el-menu-item:hover, +.sidebar-menu .el-sub-menu__title:hover { + background: rgba(64, 158, 255, 0.16); +} +.sidebar-menu .el-sub-menu.is-active > .el-sub-menu__title { + color: #fff; +} +.sidebar-drawer--light + .sidebar-menu + .el-sub-menu.is-active + > .el-sub-menu__title { + color: var(--primary-color); +} +.sidebar-menu .el-menu--inline { + background: transparent !important; +} +.sidebar-item__content { + display: inline-flex; + width: 100%; + align-items: center; + gap: 12px; +} +.sidebar-item__content svg { + flex: 0 0 auto; +} +.layout-icon { + display: block; + flex: 0 0 auto; + vertical-align: middle; +} +.sidebar-item__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.el-menu--collapse .sidebar-item__content { + justify-content: center; +} .admin-navbar { position: relative; @@ -305,84 +1230,418 @@ button, input { font: inherit; } border-bottom: 1px solid #e6e6e6; background: #fff; } -.fixed-header .admin-navbar { position: sticky; top: 0; } -.navbar-left, .navbar-right, .breadcrumb, .user-menu { display: flex; align-items: center; } -.navbar-left, .navbar-right { min-width: 0; } -.navbar-right { gap: 4px; } -.navbar-icon-button { width: 34px; height: 34px; padding: 0; color: #606266; } -.navbar-icon-button:hover, .navbar-icon-button:focus-visible { color: var(--primary-color); background: #ecf5ff; } -.breadcrumb { gap: 8px; margin-left: 8px; color: #606266; font-size: 13px; white-space: nowrap; } -.breadcrumb-separator { color: #c0c4cc; } -.breadcrumb-current { color: #303133; } -.user-menu { gap: 7px; padding: 4px 2px 4px 8px; border: 0; color: #606266; background: transparent; cursor: pointer; } -.user-menu:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } -.user-avatar { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 50%; color: #fff; background: #c0c4cc; font-size: 13px; } -.user-name { max-width: 120px; overflow: hidden; color: #606266; text-overflow: ellipsis; white-space: nowrap; } +.fixed-header .admin-navbar { + position: sticky; + top: 0; +} +.navbar-left, +.navbar-right, +.breadcrumb, +.user-menu { + display: flex; + align-items: center; +} +.navbar-left, +.navbar-right { + min-width: 0; +} +.navbar-right { + gap: 4px; +} +.navbar-icon-button { + width: 34px; + height: 34px; + padding: 0; + color: #606266; +} +.navbar-icon-button:hover, +.navbar-icon-button:focus-visible { + color: var(--primary-color); + background: #ecf5ff; +} +.breadcrumb { + gap: 8px; + margin-left: 8px; + color: #606266; + font-size: 13px; + white-space: nowrap; +} +.breadcrumb-separator { + color: #c0c4cc; +} +.breadcrumb-current { + color: #303133; +} +.user-menu { + gap: 7px; + padding: 4px 2px 4px 8px; + border: 0; + color: #606266; + background: transparent; + cursor: pointer; +} +.user-menu:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} +.user-avatar { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: 50%; + color: #fff; + background: #c0c4cc; + font-size: 13px; +} +.user-name { + max-width: 120px; + overflow: hidden; + color: #606266; + text-overflow: ellipsis; + white-space: nowrap; +} -.top-menu { flex: 0 0 50px; border-bottom: 1px solid #e6e6e6; background: #fff; } -.top-menu__bar { height: 50px; border-bottom: 0; } -.top-menu__bar .el-menu-item, .top-menu__bar .el-sub-menu__title { height: 50px; line-height: 50px; } +.top-menu { + flex: 0 0 50px; + border-bottom: 1px solid #e6e6e6; + background: #fff; +} +.top-menu__bar { + height: 50px; + border-bottom: 0; +} +.top-menu__bar .el-menu-item, +.top-menu__bar .el-sub-menu__title { + height: 50px; + line-height: 50px; +} -.tags-view-container { position: relative; z-index: 800; display: flex; height: 34px; flex: 0 0 34px; align-items: center; border-bottom: 1px solid #d8dce5; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .04); } -.tags-view-list { display: flex; min-width: 0; flex: 1 1 auto; align-items: center; gap: 5px; height: 100%; overflow-x: auto; scrollbar-width: none; } -.tags-view-list::-webkit-scrollbar { display: none; } -.tags-view-item { display: inline-flex; height: 26px; flex: 0 0 auto; align-items: center; gap: 5px; padding: 0 9px; border: 1px solid transparent; border-radius: 3px; color: #606266; background: transparent; font-size: 12px; cursor: pointer; transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease; } -.tags-view-item:hover { color: var(--primary-color); background: #f5f7fa; } -.tags-view-item.is-active { color: #fff; background: var(--primary-color); border-color: var(--primary-color); } -.tag-close { display: inline-flex; width: 14px; height: 14px; align-items: center; justify-content: center; border-radius: 50%; } -.tag-close:hover { color: #fff; background: rgba(255,255,255,.3); } -.tags-view-toolbar { display: flex; height: 100%; flex: 0 0 auto; align-items: center; border-left: 1px solid #e6e6e6; } -.tags-toolbar-button { display: grid; width: 34px; height: 100%; place-items: center; border: 0; border-left: 1px solid transparent; color: #606266; background: #fff; cursor: pointer; } +.tags-view-container { + position: relative; + z-index: 800; + display: flex; + height: 34px; + flex: 0 0 34px; + align-items: center; + border-bottom: 1px solid #d8dce5; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} +.tags-view-list { + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 5px; + height: 100%; + overflow-x: auto; + scrollbar-width: none; +} +.tags-view-list::-webkit-scrollbar { + display: none; +} +.tags-view-item { + display: inline-flex; + height: 26px; + flex: 0 0 auto; + align-items: center; + gap: 5px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 3px; + color: #606266; + background: transparent; + font-size: 12px; + cursor: pointer; + transition: + color 160ms ease, + background-color 160ms ease, + border-color 160ms ease; +} +.tags-view-item:hover { + color: var(--primary-color); + background: #f5f7fa; +} +.tags-view-item.is-active { + color: #fff; + background: var(--primary-color); + border-color: var(--primary-color); +} +.tag-close { + display: inline-flex; + width: 14px; + height: 14px; + align-items: center; + justify-content: center; + border-radius: 50%; +} +.tag-close:hover { + color: #fff; + background: rgba(255, 255, 255, 0.3); +} +.tags-view-toolbar { + display: flex; + height: 100%; + flex: 0 0 auto; + align-items: center; + border-left: 1px solid #e6e6e6; +} +.tags-toolbar-button { + display: grid; + width: 34px; + height: 100%; + place-items: center; + border: 0; + border-left: 1px solid transparent; + color: #606266; + background: #fff; + cursor: pointer; +} .tags-view-toolbar .tags-toolbar-button + .el-dropdown, -.tags-view-toolbar .el-dropdown + .tags-toolbar-button { border-left: 1px solid #e6e6e6; } -.tags-toolbar-button:hover, .tags-toolbar-button:focus-visible { color: var(--primary-color); background: #f5f7fa; } -.tags-context-menu { position: fixed; z-index: 3000; display: grid; min-width: 150px; padding: 5px 0; border: 1px solid #ebeef5; border-radius: 4px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,.12); } -.tags-context-menu button { padding: 8px 14px; border: 0; color: #606266; background: #fff; text-align: left; font-size: 12px; cursor: pointer; } -.tags-context-menu button:hover:not(:disabled) { color: var(--primary-color); background: #f5f7fa; } -.tags-context-menu button:disabled { color: #c0c4cc; cursor: not-allowed; } +.tags-view-toolbar .el-dropdown + .tags-toolbar-button { + border-left: 1px solid #e6e6e6; +} +.tags-toolbar-button:hover, +.tags-toolbar-button:focus-visible { + color: var(--primary-color); + background: #f5f7fa; +} +.tags-context-menu { + position: fixed; + z-index: 3000; + display: grid; + min-width: 150px; + padding: 5px 0; + border: 1px solid #ebeef5; + border-radius: 4px; + background: #fff; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); +} +.tags-context-menu button { + padding: 8px 14px; + border: 0; + color: #606266; + background: #fff; + text-align: left; + font-size: 12px; + cursor: pointer; +} +.tags-context-menu button:hover:not(:disabled) { + color: var(--primary-color); + background: #f5f7fa; +} +.tags-context-menu button:disabled { + color: #c0c4cc; + cursor: not-allowed; +} -.admin-main { flex: 1 1 auto; min-width: 0; padding: 24px 30px 40px; } -.admin-footer { padding: 10px 24px 14px; color: #909399; font-size: 12px; text-align: center; } -.settings-form .el-radio-group { display: flex; width: 100%; } -.settings-form .el-radio-button { flex: 1; } -.settings-form .el-radio-button__inner { width: 100%; padding-right: 8px; padding-left: 8px; } -.settings-switches { display: grid; width: 100%; gap: 14px; } -.settings-switches > div { display: flex; align-items: center; justify-content: space-between; color: #606266; font-size: 13px; } +.admin-main { + flex: 1 1 auto; + min-width: 0; + padding: 24px 30px 40px; +} +.admin-footer { + padding: 10px 24px 14px; + color: #909399; + font-size: 12px; + text-align: center; +} +.settings-form .el-radio-group { + display: flex; + width: 100%; +} +.settings-form .el-radio-button { + flex: 1; +} +.settings-form .el-radio-button__inner { + width: 100%; + padding-right: 8px; + padding-left: 8px; +} +.settings-switches { + display: grid; + width: 100%; + gap: 14px; +} +.settings-switches > div { + display: flex; + align-items: center; + justify-content: space-between; + color: #606266; + font-size: 13px; +} @media (max-width: 767px) { - .sidebar-shell { position: fixed; inset: 0 auto 0 0; } - .sidebar-shell:not(.sidebar-shell--mobile) { display: block; } - .admin-main { padding: 20px 22px 32px; } + .sidebar-shell { + position: fixed; + inset: 0 auto 0 0; + } + .sidebar-shell:not(.sidebar-shell--mobile) { + display: block; + } + .admin-main { + padding: 20px 22px 32px; + } } @media (max-width: 640px) { - .admin-navbar { padding: 0 8px; } - .breadcrumb { margin-left: 4px; } - .breadcrumb span:not(.breadcrumb-current), .navbar-right .navbar-icon-button:nth-child(-n+2), .navbar-right .el-divider { display: none; } - .user-name { display: none; } - .admin-main { padding: 18px 14px 28px; } - .tags-view-item { padding: 0 7px; } + .admin-navbar { + padding: 0 8px; + } + .breadcrumb { + margin-left: 4px; + } + .breadcrumb span:not(.breadcrumb-current), + .navbar-right .navbar-icon-button:nth-child(-n + 2), + .navbar-right .el-divider { + display: none; + } + .user-name { + display: none; + } + .admin-main { + padding: 18px 14px 28px; + } + .tags-view-item { + padding: 0 7px; + } } @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} +.login-page { + display: grid; + grid-template-columns: minmax(0, 1fr) 420px; + min-height: 100vh; + padding: 5vw 9vw; + gap: 8vw; + align-items: center; + background: #f8fafc; +} +.login-art { + max-width: 560px; +} +.login-art h1 { + margin: 18px 0; + color: #172554; + font-size: clamp(34px, 4vw, 61px); + letter-spacing: -0.05em; + line-height: 1.07; +} +.login-art em { + color: #2563eb; + font-style: normal; +} +.login-art > p:last-child { + max-width: 390px; + color: #64748b; + line-height: 1.9; +} +.login-card { + padding: 30px; + border: 1px solid #e2e8f0; + border-radius: 16px; +} +.login-card-heading { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 30px; +} +.login-card-heading h2 { + margin: 0; + font-size: 22px; +} +.login-card-heading p { + margin: 5px 0 0; + color: var(--muted); + font-size: 13px; +} +.login-error { + margin: 4px 0 17px; +} +.login-remember { + margin: -4px 0 12px; +} +.login-captcha-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 160px; + gap: 10px; + align-items: center; + width: 100%; +} +.login-captcha-image { + display: flex; + width: 160px; + height: 32px; + align-items: center; + justify-content: center; + padding: 0; + overflow: hidden; + border: 1px solid #dcdfe6; + border-radius: 4px; + color: #909399; + background: #f5f7fa; + cursor: pointer; +} +.login-captcha-image:hover:not(:disabled), +.login-captcha-image:focus-visible { + border-color: var(--primary-color); +} +.login-captcha-image:disabled { + cursor: wait; + opacity: 0.7; +} +.login-captcha-image img { + display: block; + width: 160px; + height: 48px; + object-fit: cover; +} +.login-button { + width: 100%; + height: 42px; +} +@media (max-width: 900px) { + .login-page { + grid-template-columns: 1fr; + padding: 48px 24px; + gap: 24px; + } + .login-art { + max-width: none; + } + .login-art h1 { + font-size: 38px; + } + .login-card { + max-width: 520px; + width: 100%; + } + .advisor-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 640px) { + .user-name { + display: none; + } + .metric-grid, + .module-grid, + .loading-grid, + .advisor-grid { + grid-template-columns: 1fr; + } + .page-intro { + align-items: flex-start; + flex-direction: column; + } } -.login-page { display: grid; grid-template-columns: minmax(0, 1fr) 420px; min-height: 100vh; padding: 5vw 9vw; gap: 8vw; align-items: center; background: #f8fafc; } -.login-art { max-width: 560px; } -.login-art h1 { margin: 18px 0; color: #172554; font-size: clamp(34px, 4vw, 61px); letter-spacing: -.05em; line-height: 1.07; } -.login-art em { color: #2563eb; font-style: normal; } -.login-art > p:last-child { max-width: 390px; color: #64748b; line-height: 1.9; } -.login-card { padding: 30px; border: 1px solid #e2e8f0; border-radius: 16px; } -.login-card-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 30px; } -.login-card-heading h2 { margin: 0; font-size: 22px; } -.login-card-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; } -.login-error { margin: 4px 0 17px; } -.login-remember { margin: -4px 0 12px; } -.login-captcha-row { display: grid; grid-template-columns: minmax(0, 1fr) 160px; gap: 10px; align-items: center; width: 100%; } -.login-captcha-image { display: flex; width: 160px; height: 32px; align-items: center; justify-content: center; padding: 0; overflow: hidden; border: 1px solid #dcdfe6; border-radius: 4px; color: #909399; background: #f5f7fa; cursor: pointer; } -.login-captcha-image:hover:not(:disabled), .login-captcha-image:focus-visible { border-color: var(--primary-color); } -.login-captcha-image:disabled { cursor: wait; opacity: .7; } -.login-captcha-image img { display: block; width: 160px; height: 48px; object-fit: cover; } -.login-button { width: 100%; height: 42px; } -@media (max-width: 900px) { .login-page { grid-template-columns: 1fr; padding: 48px 24px; gap: 24px; } .login-art { max-width: none; } .login-art h1 { font-size: 38px; } .login-card { max-width: 520px; width: 100%; } .advisor-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 640px) { .user-name { display: none; } .metric-grid, .module-grid, .loading-grid, .advisor-grid { grid-template-columns: 1fr; } .page-intro { align-items: flex-start; flex-direction: column; } } diff --git a/WonderQ-Admin/alembic/versions/0001_initial_schema.py b/WonderQ-Admin/alembic/versions/0001_initial_schema.py index ea69dc4..b7d02c4 100644 --- a/WonderQ-Admin/alembic/versions/0001_initial_schema.py +++ b/WonderQ-Admin/alembic/versions/0001_initial_schema.py @@ -5,7 +5,10 @@ Revises: Create Date: 2026-06-30 """ +from datetime import datetime, timezone + from alembic import context, op +import sqlalchemy as sa from sqlalchemy import inspect from app.database import Base from app import models # noqa: F401 @@ -16,6 +19,41 @@ down_revision = None branch_labels = None depends_on = None +DEFAULT_DEPT_ID = "00000000-0000-0000-0000-000000000001" + + +def _seed_default_department(bind) -> None: + department_table = sa.table( + "AdminDepartment", + sa.column("id", sa.String()), + sa.column("name", sa.String()), + sa.column("sortOrder", sa.Integer()), + sa.column("isActive", sa.Boolean()), + sa.column("createdAt", sa.DateTime()), + sa.column("updatedAt", sa.DateTime()), + ) + if context.is_offline_mode(): + should_seed = True + else: + should_seed = bind.execute( + sa.text('SELECT 1 FROM "AdminDepartment" LIMIT 1') + ).first() is None + if should_seed: + timestamp = datetime.now(timezone.utc).replace(tzinfo=None) + op.bulk_insert( + department_table, + [ + { + "id": DEFAULT_DEPT_ID, + "name": "默认部门", + "sortOrder": 0, + "isActive": True, + "createdAt": timestamp, + "updatedAt": timestamp, + } + ], + ) + def upgrade() -> None: bind = op.get_bind() @@ -26,6 +64,8 @@ def upgrade() -> None: for table in Base.metadata.sorted_tables: if table.name not in existing_tables: table.create(bind) + if "AdminDepartment" in Base.metadata.tables: + _seed_default_department(bind) def downgrade() -> None: diff --git a/WonderQ-Admin/alembic/versions/0015_wanfa.py b/WonderQ-Admin/alembic/versions/0015_wanfa.py index 8e66463..69c09b5 100644 --- a/WonderQ-Admin/alembic/versions/0015_wanfa.py +++ b/WonderQ-Admin/alembic/versions/0015_wanfa.py @@ -10,6 +10,8 @@ from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect +from app.migration_compat import baseline_table_names + revision = "0015_wanfa" down_revision = "0001_initial_schema" @@ -99,8 +101,11 @@ def _seed_wanfa() -> None: def upgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) + if context.is_offline_mode(): + existing_tables = baseline_table_names() + else: + bind = op.get_bind() + existing_tables = set(inspect(bind).get_table_names()) if "WanfaCategory" not in existing_tables: op.create_table( diff --git a/WonderQ-Admin/alembic/versions/0016_concierge_advisors.py b/WonderQ-Admin/alembic/versions/0016_concierge_advisors.py index a226ef7..e909fdc 100644 --- a/WonderQ-Admin/alembic/versions/0016_concierge_advisors.py +++ b/WonderQ-Admin/alembic/versions/0016_concierge_advisors.py @@ -5,11 +5,14 @@ Revises: 0015_wanfa """ from datetime import datetime +import json from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect +from app.migration_compat import baseline_table_names + revision = "0016_concierge_advisors" down_revision = "0015_wanfa" @@ -60,14 +63,23 @@ SEED_ADVISORS = ( ) +def is_offline_mode() -> bool: + try: + return context.is_offline_mode() + except (NameError, AttributeError): + return False + + def _seed_advisors() -> None: + offline = is_offline_mode() + details_type = sa.Text() if offline else sa.JSON() table = sa.table( "ConciergeAdvisor", sa.column("id", sa.String()), sa.column("avatar", sa.String()), sa.column("name", sa.String()), sa.column("role", sa.String()), - sa.column("details", sa.JSON()), + sa.column("details", details_type), sa.column("qrImage", sa.String()), sa.column("isActive", sa.Boolean()), sa.column("sortOrder", sa.Integer()), @@ -75,15 +87,18 @@ def _seed_advisors() -> None: sa.column("updatedAt", sa.DateTime()), ) timestamp = datetime(2026, 1, 1) - op.bulk_insert( - table, - [{**advisor, "createdAt": timestamp, "updatedAt": timestamp} for advisor in SEED_ADVISORS], - ) + rows = [{**advisor, "createdAt": timestamp, "updatedAt": timestamp} for advisor in SEED_ADVISORS] + if offline: + rows = [{**advisor, "details": json.dumps(advisor["details"], ensure_ascii=False)} for advisor in rows] + op.bulk_insert(table, rows) def upgrade() -> None: - bind = op.get_bind() - existing_tables = set(inspect(bind).get_table_names()) + if is_offline_mode(): + existing_tables = baseline_table_names() + else: + bind = op.get_bind() + existing_tables = set(inspect(bind).get_table_names()) if "ConciergeAdvisor" not in existing_tables: op.create_table( "ConciergeAdvisor", @@ -102,7 +117,7 @@ def upgrade() -> None: op.create_index("ix_ConciergeAdvisor_isActive", "ConciergeAdvisor", ["isActive"]) op.create_index("ix_ConciergeAdvisor_sortOrder", "ConciergeAdvisor", ["sortOrder"]) - if context.is_offline_mode(): + if is_offline_mode(): _seed_advisors() return diff --git a/WonderQ-Admin/alembic/versions/0017_home_content.py b/WonderQ-Admin/alembic/versions/0017_home_content.py index 9471f7f..1f54975 100644 --- a/WonderQ-Admin/alembic/versions/0017_home_content.py +++ b/WonderQ-Admin/alembic/versions/0017_home_content.py @@ -5,12 +5,15 @@ Revises: 0016_concierge_advisors """ from datetime import datetime +import json from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect from sqlalchemy.dialects import postgresql +from app.migration_compat import baseline_column_names, baseline_table_names + revision = "0017_home_content" down_revision = "0016_concierge_advisors" @@ -142,6 +145,13 @@ SEED_WILD_ARCHIVES = ( def _create_table(table_name: str, columns: list[sa.Column]) -> None: + if is_offline_mode(): + if table_name in baseline_table_names(): + return + op.create_table(table_name, *columns, sa.PrimaryKeyConstraint("id")) + op.create_index(f"ix_{table_name}_isActive", table_name, ["isActive"]) + op.create_index(f"ix_{table_name}_sortOrder", table_name, ["sortOrder"]) + return bind = op.get_bind() if table_name in set(inspect(bind).get_table_names()): return @@ -178,6 +188,11 @@ def _seed_table(table_name: str, columns: list[sa.Column], rows: tuple[dict, ... column_types = {column.name: column.type for column in columns} if is_offline_mode(): available_columns = set(column_types) + if table_name in baseline_table_names(): + available_columns.update(baseline_column_names(table_name)) + for name, column_type in OPTIONAL_SEED_COLUMN_TYPES.items(): + if name in available_columns: + column_types.setdefault(name, column_type) else: bind = op.get_bind() current_columns = inspect(bind).get_columns(table_name) @@ -200,7 +215,31 @@ def _seed_table(table_name: str, columns: list[sa.Column], rows: tuple[dict, ... ], ) timestamp = datetime(2026, 1, 1) - op.bulk_insert(table, prepare_seed_rows(rows, seed_column_names, timestamp)) + seed_rows = prepare_seed_rows(rows, seed_column_names, timestamp) + if is_offline_mode(): + seed_rows = [ + { + **row, + **{ + name: json.dumps(row[name], ensure_ascii=False) + for name in ("detailParagraphs", "images") + if name in row + }, + } + for row in seed_rows + ] + for name in ("detailParagraphs", "images"): + if name in column_types: + column_types[name] = sa.Text() + table = sa.table( + table_name, + *[ + sa.column(name, column_types[name]) + for name in column_types + if name in seed_column_names + ], + ) + op.bulk_insert(table, seed_rows) def upgrade() -> None: diff --git a/WonderQ-Admin/alembic/versions/0018_home_wanfa_recommendations.py b/WonderQ-Admin/alembic/versions/0018_home_wanfa_recommendations.py index 4f67cec..6b8495b 100644 --- a/WonderQ-Admin/alembic/versions/0018_home_wanfa_recommendations.py +++ b/WonderQ-Admin/alembic/versions/0018_home_wanfa_recommendations.py @@ -4,10 +4,12 @@ Revision ID: 0018_home_wanfa_recommendations Revises: 0017_home_content """ -from alembic import op +from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect +from app.migration_compat import baseline_table_names + revision = "0018_home_wanfa_recommendations" down_revision = "0017_home_content" @@ -15,7 +17,16 @@ branch_labels = None depends_on = None +def is_offline_mode() -> bool: + try: + return context.is_offline_mode() + except (NameError, AttributeError): + return False + + def upgrade() -> None: + if "HomeWanfaRecommendation" in baseline_table_names() and is_offline_mode(): + return bind = op.get_bind() if "HomeWanfaRecommendation" in set(inspect(bind).get_table_names()): return diff --git a/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py b/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py index 721841f..defefd5 100644 --- a/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py +++ b/WonderQ-Admin/alembic/versions/0019_home_wild_archive_images.py @@ -9,6 +9,8 @@ import sqlalchemy as sa from sqlalchemy import inspect from sqlalchemy.dialects import postgresql +from app.migration_compat import baseline_column_names + revision = "0019_home_wild_archive_images" down_revision = "0018_home_wanfa_recommendations" @@ -29,7 +31,7 @@ def is_offline_mode() -> bool: def upgrade() -> None: if is_offline_mode(): - add_images_column = True + add_images_column = "images" not in baseline_column_names("HomeWildArchive") else: bind = op.get_bind() existing_columns = {column["name"] for column in inspect(bind).get_columns("HomeWildArchive")} diff --git a/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py b/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py index 7f253fc..b315738 100644 --- a/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py +++ b/WonderQ-Admin/alembic/versions/0020_home_team_building_details.py @@ -9,6 +9,8 @@ import sqlalchemy as sa from sqlalchemy import inspect from sqlalchemy.dialects import postgresql +from app.migration_compat import baseline_column_names + revision = "0020_home_team_building_details" down_revision = "0019_home_wild_archive_images" @@ -32,7 +34,9 @@ def is_offline_mode() -> bool: def upgrade() -> None: if is_offline_mode(): - columns_to_add = DETAIL_COLUMNS + columns_to_add = missing_detail_columns( + baseline_column_names("HomeTeamBuilding") + ) else: bind = op.get_bind() existing_columns = {column["name"] for column in inspect(bind).get_columns("HomeTeamBuilding")} diff --git a/WonderQ-Admin/alembic/versions/0021_detail_records.py b/WonderQ-Admin/alembic/versions/0021_detail_records.py index f542cad..bb8344c 100644 --- a/WonderQ-Admin/alembic/versions/0021_detail_records.py +++ b/WonderQ-Admin/alembic/versions/0021_detail_records.py @@ -11,6 +11,8 @@ import sqlalchemy as sa from sqlalchemy import inspect from sqlalchemy.dialects import postgresql +from app.migration_compat import baseline_table_names + revision = "0021_detail_records" down_revision = "0020_home_team_building_details" @@ -86,7 +88,7 @@ def _create_detail_table() -> None: def upgrade() -> None: if is_offline_mode(): - table_exists = False + table_exists = "DetailRecord" in baseline_table_names() existing_index_names = set() else: bind = op.get_bind() diff --git a/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py b/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py index f3276de..42136ac 100644 --- a/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py +++ b/WonderQ-Admin/alembic/versions/0023_detail_concierge_advisor.py @@ -8,6 +8,8 @@ from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect +from app.migration_compat import baseline_column_names + revision = "0023_detail_concierge_advisor" down_revision = "0022_opaque_ids" @@ -28,7 +30,7 @@ def is_offline_mode() -> bool: def upgrade() -> None: if is_offline_mode(): - should_add = True + should_add = "conciergeAdvisorId" not in baseline_column_names("DetailRecord") else: existing_columns = { column["name"] diff --git a/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py b/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py index e8d5f4a..75ceeeb 100644 --- a/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py +++ b/WonderQ-Admin/alembic/versions/0024_vehicle_demand.py @@ -5,6 +5,13 @@ import sqlalchemy as sa from sqlalchemy import inspect from sqlalchemy.dialects import postgresql +from app.migration_compat import ( + baseline_column_names, + baseline_foreign_keys, + baseline_index_names, + baseline_table_names, +) + revision = "0024_vehicle_demand" down_revision = "0023_detail_concierge_advisor" @@ -55,10 +62,10 @@ def is_offline_mode() -> bool: def upgrade() -> None: if is_offline_mode(): - existing_lead_columns = set() - existing_lead_indexes = set() - existing_lead_foreign_keys = [] - vehicle_service_table_exists = False + existing_lead_columns = baseline_column_names("Lead") + existing_lead_indexes = baseline_index_names("Lead") + existing_lead_foreign_keys = baseline_foreign_keys("Lead") + vehicle_service_table_exists = "VehicleServiceConfig" in baseline_table_names() existing_vehicle_service_indexes = set() else: inspector = inspect(op.get_bind()) diff --git a/WonderQ-Admin/alembic/versions/0025_admin_rbac.py b/WonderQ-Admin/alembic/versions/0025_admin_rbac.py index 4b0c338..ad2b46b 100644 --- a/WonderQ-Admin/alembic/versions/0025_admin_rbac.py +++ b/WonderQ-Admin/alembic/versions/0025_admin_rbac.py @@ -2,8 +2,9 @@ from datetime import datetime, timezone -from alembic import op +from alembic import context, op import sqlalchemy as sa +from sqlalchemy import inspect revision = "0025_admin_rbac" @@ -37,10 +38,40 @@ MENU_ROWS = ( ) +def is_offline_mode() -> bool: + try: + return context.is_offline_mode() + except (NameError, AttributeError): + return False + + +def _existing_table_names() -> set[str]: + if is_offline_mode(): + # The initial baseline creates the current RBAC tables before this + # revision runs, but an offline connection cannot inspect them. + return set(RBAC_TABLES) + return set(inspect(op.get_bind()).get_table_names()) + + +def _create_table_if_missing(existing_tables: set[str], table_name: str, *elements) -> None: + if table_name in existing_tables: + return + op.create_table(table_name, *elements) + existing_tables.add(table_name) + + +def _table_has_rows(table_name: str) -> bool: + if is_offline_mode(): + return False + return op.get_bind().execute(sa.text(f'SELECT 1 FROM "{table_name}" LIMIT 1')).first() is not None + + def upgrade() -> None: now = datetime.now(timezone.utc).replace(tzinfo=None) + existing_tables = _existing_table_names() - op.create_table( + _create_table_if_missing( + existing_tables, "AdminRole", sa.Column("id", sa.String(), nullable=False), sa.Column("name", sa.String(), nullable=False), @@ -53,7 +84,8 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("code", name="uq_AdminRole_code"), ) - op.create_table( + _create_table_if_missing( + existing_tables, "AdminDepartment", sa.Column("id", sa.String(), nullable=False), sa.Column("parentId", sa.String(), nullable=True), @@ -65,7 +97,8 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["parentId"], ["AdminDepartment.id"], ondelete="SET NULL"), sa.PrimaryKeyConstraint("id"), ) - op.create_table( + _create_table_if_missing( + existing_tables, "AdminMenu", sa.Column("id", sa.String(), nullable=False), sa.Column("parentId", sa.String(), nullable=True), @@ -84,7 +117,8 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("permissionCode", name="uq_AdminMenu_permissionCode"), ) - op.create_table( + _create_table_if_missing( + existing_tables, "AdminUserRole", sa.Column("userId", sa.String(), nullable=False), sa.Column("roleId", sa.String(), nullable=False), @@ -93,7 +127,8 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["userId"], ["AdminUser.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("userId", "roleId"), ) - op.create_table( + _create_table_if_missing( + existing_tables, "AdminRoleMenu", sa.Column("roleId", sa.String(), nullable=False), sa.Column("menuId", sa.String(), nullable=False), @@ -102,7 +137,8 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["roleId"], ["AdminRole.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("roleId", "menuId"), ) - op.create_table( + _create_table_if_missing( + existing_tables, "AdminUserDepartment", sa.Column("userId", sa.String(), nullable=False), sa.Column("deptId", sa.String(), nullable=False), @@ -111,7 +147,8 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["userId"], ["AdminUser.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("userId", "deptId"), ) - op.create_table( + _create_table_if_missing( + existing_tables, "AdminRoleDepartment", sa.Column("roleId", sa.String(), nullable=False), sa.Column("deptId", sa.String(), nullable=False), @@ -124,15 +161,18 @@ def upgrade() -> None: role_table = sa.table("AdminRole", sa.column("id", sa.String), sa.column("name", sa.String), sa.column("code", sa.String), sa.column("dataScope", sa.String), sa.column("isActive", sa.Boolean), sa.column("createdAt", sa.DateTime), sa.column("updatedAt", sa.DateTime)) dept_table = sa.table("AdminDepartment", sa.column("id", sa.String), sa.column("name", sa.String), sa.column("sortOrder", sa.Integer), sa.column("isActive", sa.Boolean), sa.column("createdAt", sa.DateTime), sa.column("updatedAt", sa.DateTime)) menu_table = sa.table("AdminMenu", sa.column("id", sa.String), sa.column("name", sa.String), sa.column("type", sa.String), sa.column("path", sa.String), sa.column("componentKey", sa.String), sa.column("permissionCode", sa.String), sa.column("sortOrder", sa.Integer), sa.column("isVisible", sa.Boolean), sa.column("isActive", sa.Boolean), sa.column("createdAt", sa.DateTime), sa.column("updatedAt", sa.DateTime)) - op.bulk_insert(role_table, [{"id": SUPER_ROLE_ID, "name": "超级管理员", "code": "super_admin", "dataScope": "all", "isActive": True, "createdAt": now, "updatedAt": now}]) - op.bulk_insert(dept_table, [{"id": DEFAULT_DEPT_ID, "name": "默认部门", "sortOrder": 0, "isActive": True, "createdAt": now, "updatedAt": now}]) - op.bulk_insert( - menu_table, - [ - {"id": menu_id, "name": name, "type": menu_type, "path": path, "componentKey": component, "permissionCode": permission, "sortOrder": sort_order, "isVisible": True, "isActive": True, "createdAt": now, "updatedAt": now} - for menu_id, name, menu_type, path, component, permission, sort_order in MENU_ROWS - ], - ) + if not _table_has_rows("AdminRole"): + op.bulk_insert(role_table, [{"id": SUPER_ROLE_ID, "name": "超级管理员", "code": "super_admin", "dataScope": "all", "isActive": True, "createdAt": now, "updatedAt": now}]) + if not _table_has_rows("AdminDepartment"): + op.bulk_insert(dept_table, [{"id": DEFAULT_DEPT_ID, "name": "默认部门", "sortOrder": 0, "isActive": True, "createdAt": now, "updatedAt": now}]) + if not _table_has_rows("AdminMenu"): + op.bulk_insert( + menu_table, + [ + {"id": menu_id, "name": name, "type": menu_type, "path": path, "componentKey": component, "permissionCode": permission, "sortOrder": sort_order, "isVisible": True, "isActive": True, "createdAt": now, "updatedAt": now} + for menu_id, name, menu_type, path, component, permission, sort_order in MENU_ROWS + ], + ) op.execute(sa.text('INSERT INTO "AdminRoleMenu" ("roleId", "menuId", "createdAt") SELECT :role_id, "id", :created_at FROM "AdminMenu" ON CONFLICT DO NOTHING').bindparams(role_id=SUPER_ROLE_ID, created_at=now)) op.execute(sa.text('INSERT INTO "AdminRoleDepartment" ("roleId", "deptId", "createdAt") VALUES (:role_id, :dept_id, :created_at) ON CONFLICT DO NOTHING').bindparams(role_id=SUPER_ROLE_ID, dept_id=DEFAULT_DEPT_ID, created_at=now)) op.execute(sa.text('INSERT INTO "AdminUserRole" ("userId", "roleId", "createdAt") SELECT "id", :role_id, :created_at FROM "AdminUser" ON CONFLICT DO NOTHING').bindparams(role_id=SUPER_ROLE_ID, created_at=now)) diff --git a/WonderQ-Admin/alembic/versions/0026_admin_ownership.py b/WonderQ-Admin/alembic/versions/0026_admin_ownership.py index 991d73e..1814984 100644 --- a/WonderQ-Admin/alembic/versions/0026_admin_ownership.py +++ b/WonderQ-Admin/alembic/versions/0026_admin_ownership.py @@ -1,7 +1,8 @@ """Add department and creator ownership to operator-managed resources.""" -from alembic import op +from alembic import context, op import sqlalchemy as sa +from sqlalchemy import inspect revision = "0026_admin_ownership" @@ -31,56 +32,140 @@ OWNED_TABLES = ( ) +def is_offline_mode() -> bool: + try: + return context.is_offline_mode() + except (NameError, AttributeError): + return False + + +def _schema_snapshot() -> tuple[set[str], dict[str, dict[str, dict]], dict[str, set[str]], dict[str, list[dict]]]: + if is_offline_mode(): + return set(), {}, {}, {} + + inspector = inspect(op.get_bind()) + table_names = set(inspector.get_table_names()) + columns = { + table_name: {column["name"]: column for column in inspector.get_columns(table_name)} + for table_name in table_names + } + indexes = { + table_name: { + index["name"] + for index in inspector.get_indexes(table_name) + if index.get("name") + } + for table_name in table_names + } + foreign_keys = { + table_name: inspector.get_foreign_keys(table_name) + for table_name in table_names + } + return table_names, columns, indexes, foreign_keys + + +def _has_foreign_key( + foreign_keys: list[dict], + constrained_column: str, + referred_table: str, +) -> bool: + return any( + foreign_key.get("constrained_columns") == [constrained_column] + and foreign_key.get("referred_table") == referred_table + and foreign_key.get("referred_columns") == ["id"] + for foreign_key in foreign_keys + ) + + def upgrade() -> None: + table_names, columns, indexes, foreign_keys = _schema_snapshot() + for table_name in OWNED_TABLES: - op.add_column( - table_name, - sa.Column( - "deptId", - sa.String(), - nullable=True, - server_default=sa.text(f"'{DEFAULT_DEPT_ID}'"), - ), - ) - op.add_column(table_name, sa.Column("createdById", sa.String(), nullable=True)) - op.create_index(f"ix_{table_name}_deptId", table_name, ["deptId"]) - op.create_index(f"ix_{table_name}_createdById", table_name, ["createdById"]) - op.create_foreign_key( - f"fk_{table_name}_deptId", - table_name, - "AdminDepartment", - ["deptId"], - ["id"], - ondelete="RESTRICT", - ) - op.create_foreign_key( - f"fk_{table_name}_createdById", - table_name, - "AdminUser", - ["createdById"], - ["id"], - ondelete="SET NULL", - ) + if table_name not in table_names: + continue + + table_columns = columns[table_name] + added_dept_column = "deptId" not in table_columns + added_creator_column = "createdById" not in table_columns + if added_dept_column: + op.add_column( + table_name, + sa.Column( + "deptId", + sa.String(), + nullable=True, + server_default=sa.text(f"'{DEFAULT_DEPT_ID}'"), + ), + ) + table_columns["deptId"] = {"name": "deptId", "nullable": True} + if added_creator_column: + op.add_column(table_name, sa.Column("createdById", sa.String(), nullable=True)) + table_columns["createdById"] = {"name": "createdById", "nullable": True} + + table_indexes = indexes[table_name] + if f"ix_{table_name}_deptId" not in table_indexes: + op.create_index(f"ix_{table_name}_deptId", table_name, ["deptId"]) + table_indexes.add(f"ix_{table_name}_deptId") + if f"ix_{table_name}_createdById" not in table_indexes: + op.create_index(f"ix_{table_name}_createdById", table_name, ["createdById"]) + table_indexes.add(f"ix_{table_name}_createdById") + + table_foreign_keys = foreign_keys[table_name] + if not _has_foreign_key(table_foreign_keys, "deptId", "AdminDepartment"): + op.create_foreign_key( + f"fk_{table_name}_deptId", + table_name, + "AdminDepartment", + ["deptId"], + ["id"], + ondelete="RESTRICT", + ) + table_foreign_keys.append( + { + "constrained_columns": ["deptId"], + "referred_table": "AdminDepartment", + "referred_columns": ["id"], + } + ) + if not _has_foreign_key(table_foreign_keys, "createdById", "AdminUser"): + op.create_foreign_key( + f"fk_{table_name}_createdById", + table_name, + "AdminUser", + ["createdById"], + ["id"], + ondelete="SET NULL", + ) + table_foreign_keys.append( + { + "constrained_columns": ["createdById"], + "referred_table": "AdminUser", + "referred_columns": ["id"], + } + ) quoted_table = f'"{table_name}"' - op.execute( - sa.text(f'UPDATE {quoted_table} SET "deptId" = :dept_id WHERE "deptId" IS NULL').bindparams( - dept_id=DEFAULT_DEPT_ID + needs_dept_backfill = added_dept_column or table_columns["deptId"].get("nullable") is True + if needs_dept_backfill: + op.execute( + sa.text(f'UPDATE {quoted_table} SET "deptId" = :dept_id WHERE "deptId" IS NULL').bindparams( + dept_id=DEFAULT_DEPT_ID + ) ) - ) - op.alter_column( - table_name, - "deptId", - existing_type=sa.String(), - nullable=False, - server_default=sa.text(f"'{DEFAULT_DEPT_ID}'"), - ) - op.execute( - sa.text( - f'UPDATE {quoted_table} SET "createdById" = ' - '(SELECT "id" FROM "AdminUser" ORDER BY "createdAt" ASC LIMIT 1) ' - 'WHERE "createdById" IS NULL' + op.alter_column( + table_name, + "deptId", + existing_type=sa.String(), + nullable=False, + server_default=sa.text(f"'{DEFAULT_DEPT_ID}'"), + ) + if added_creator_column: + op.execute( + sa.text( + f'UPDATE {quoted_table} SET "createdById" = ' + '(SELECT "id" FROM "AdminUser" ORDER BY "createdAt" ASC LIMIT 1) ' + 'WHERE "createdById" IS NULL' + ) ) - ) def downgrade() -> None: diff --git a/WonderQ-Admin/alembic/versions/0034_media_assets.py b/WonderQ-Admin/alembic/versions/0034_media_assets.py index 18ff203..595885f 100644 --- a/WonderQ-Admin/alembic/versions/0034_media_assets.py +++ b/WonderQ-Admin/alembic/versions/0034_media_assets.py @@ -1,9 +1,11 @@ """Create the media asset table required by admin uploads.""" -from alembic import op +from alembic import context, op import sqlalchemy as sa from sqlalchemy import inspect +from app.migration_compat import baseline_table_names + revision = "0034_media_assets" down_revision = "0033_remove_site_versions" @@ -13,7 +15,16 @@ depends_on = None DEFAULT_DEPT_ID = "00000000-0000-0000-0000-000000000001" +def is_offline_mode() -> bool: + try: + return context.is_offline_mode() + except (NameError, AttributeError): + return False + + def _table_names() -> set[str]: + if is_offline_mode(): + return baseline_table_names() return set(inspect(op.get_bind()).get_table_names()) diff --git a/WonderQ-Admin/alembic/versions/0035_concierge_details_jsonb.py b/WonderQ-Admin/alembic/versions/0035_concierge_details_jsonb.py new file mode 100644 index 0000000..84536e5 --- /dev/null +++ b/WonderQ-Admin/alembic/versions/0035_concierge_details_jsonb.py @@ -0,0 +1,33 @@ +"""Align the concierge details column with the PostgreSQL JSONB model type.""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision = "0035_concierge_details_jsonb" +down_revision = "0034_media_assets" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column( + "ConciergeAdvisor", + "details", + existing_type=sa.JSON(), + type_=postgresql.JSONB(astext_type=sa.Text()), + existing_nullable=False, + postgresql_using='"details"::jsonb', + ) + + +def downgrade() -> None: + op.alter_column( + "ConciergeAdvisor", + "details", + existing_type=postgresql.JSONB(astext_type=sa.Text()), + type_=sa.JSON(), + existing_nullable=False, + postgresql_using='"details"::json', + ) diff --git a/WonderQ-Admin/app/migration_compat.py b/WonderQ-Admin/app/migration_compat.py new file mode 100644 index 0000000..7f495c9 --- /dev/null +++ b/WonderQ-Admin/app/migration_compat.py @@ -0,0 +1,37 @@ +"""Helpers for offline generation against the current initial baseline.""" + +from . import models # noqa: F401 +from .database import Base + + +def baseline_table_names() -> set[str]: + return set(Base.metadata.tables) + + +def baseline_column_names(table_name: str) -> set[str]: + table = Base.metadata.tables.get(table_name) + return set(table.columns.keys()) if table is not None else set() + + +def baseline_index_names(table_name: str) -> set[str]: + table = Base.metadata.tables.get(table_name) + return {index.name for index in table.indexes if index.name} if table is not None else set() + + +def baseline_foreign_keys(table_name: str) -> list[dict]: + table = Base.metadata.tables.get(table_name) + if table is None: + return [] + return [ + { + "name": foreign_key.name, + "constrained_columns": [foreign_key.parent.name], + "referred_table": foreign_key.column.table.name, + "referred_columns": [foreign_key.column.name], + "options": { + "ondelete": foreign_key.ondelete, + "onupdate": foreign_key.onupdate, + }, + } + for foreign_key in table.foreign_keys + ] diff --git a/WonderQ-Admin/tests/test_clean_database_migration_path.py b/WonderQ-Admin/tests/test_clean_database_migration_path.py new file mode 100644 index 0000000..aafabc4 --- /dev/null +++ b/WonderQ-Admin/tests/test_clean_database_migration_path.py @@ -0,0 +1,215 @@ +from importlib.util import module_from_spec, spec_from_file_location +import subprocess +from pathlib import Path +import sys + +import pytest + +VERSIONS_DIR = Path(__file__).parents[1] / "alembic" / "versions" + + +def load_migration(filename: str): + path = VERSIONS_DIR / filename + spec = spec_from_file_location(f"clean_database_{path.stem}", path) + assert spec and spec.loader + migration = module_from_spec(spec) + spec.loader.exec_module(migration) + return migration + + +class ExistingRows: + def first(self): + return ("existing",) + + +class EmptyRows: + def first(self): + return None + + +class ExistingBind: + def execute(self, _statement): + return ExistingRows() + + +class EmptyBind: + def execute(self, _statement): + return EmptyRows() + + +class CurrentSchemaInspector: + tables = { + "AdminDepartment", + "AdminMenu", + "AdminRole", + "AdminRoleDepartment", + "AdminRoleMenu", + "AdminUser", + "AdminUserDepartment", + "AdminUserRole", + "AuditLog", + "ConciergeAdvisor", + "Customer", + "DetailRecord", + "HeroSlide", + "HomeTeamBuilding", + "HomeWanfaRecommendation", + "HomeWildArchive", + "Lead", + "LeadFollowup", + "MediaAsset", + "Order", + "VehicleOption", + "WanfaCategory", + "WanfaRoute", + } + + def get_table_names(self): + return sorted(self.tables) + + def get_columns(self, table_name): + if table_name not in self.tables: + return [] + return [{"name": "deptId"}, {"name": "createdById"}] + + def get_indexes(self, table_name): + if table_name not in self.tables: + return [] + return [ + {"name": f"ix_{table_name}_deptId"}, + {"name": f"ix_{table_name}_createdById"}, + ] + + def get_foreign_keys(self, table_name): + if table_name not in self.tables: + return [] + return [ + { + "name": f"fk_{table_name}_deptId", + "constrained_columns": ["deptId"], + "referred_table": "AdminDepartment", + "referred_columns": ["id"], + }, + { + "name": f"fk_{table_name}_createdById", + "constrained_columns": ["createdById"], + "referred_table": "AdminUser", + "referred_columns": ["id"], + }, + ] + + +def test_admin_rbac_does_not_recreate_tables_already_created_by_initial_baseline(monkeypatch): + migration = load_migration("0025_admin_rbac.py") + inspector = CurrentSchemaInspector() + calls = [] + + monkeypatch.setattr(migration, "is_offline_mode", lambda: False, raising=False) + monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False) + monkeypatch.setattr(migration.op, "get_bind", lambda: ExistingBind()) + monkeypatch.setattr( + migration.op, + "create_table", + lambda *args, **_kwargs: calls.append(("create_table", args[0])), + ) + monkeypatch.setattr( + migration.op, + "bulk_insert", + lambda *args, **_kwargs: calls.append(("bulk_insert", args[0].name)), + ) + monkeypatch.setattr( + migration.op, + "execute", + lambda *_args, **_kwargs: calls.append(("execute",)), + ) + + migration.upgrade() + + assert [call for call in calls if call[0] in {"create_table", "bulk_insert"}] == [] + + +def test_initial_baseline_seeds_default_department_before_owned_data(monkeypatch): + migration = load_migration("0001_initial_schema.py") + inspector = CurrentSchemaInspector() + calls = [] + + monkeypatch.setattr(migration.context, "is_offline_mode", lambda: False) + monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False) + monkeypatch.setattr(migration.op, "get_bind", lambda: EmptyBind()) + monkeypatch.setattr( + migration.op, + "bulk_insert", + lambda table, rows: calls.append((table.name, rows)), + ) + + migration.upgrade() + + assert [table_name for table_name, _rows in calls] == ["AdminDepartment"] + assert calls[0][1][0]["id"] == migration.DEFAULT_DEPT_ID + + +def test_concierge_details_migration_converts_json_to_jsonb(monkeypatch): + migration_path = VERSIONS_DIR / "0035_concierge_details_jsonb.py" + if not migration_path.exists(): + pytest.fail("0035_concierge_details_jsonb.py is required to align the model type") + migration = load_migration("0035_concierge_details_jsonb.py") + calls = [] + + monkeypatch.setattr( + migration.op, + "alter_column", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + migration.upgrade() + + assert migration.down_revision == "0034_media_assets" + assert calls[0][0][:2] == ("ConciergeAdvisor", "details") + assert calls[0][1]["postgresql_using"] == '"details"::jsonb' + + +def test_offline_upgrade_generates_sql_for_a_clean_database(): + result = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head", "--sql"], + cwd=VERSIONS_DIR.parents[1], + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0, result.stderr[-4000:] + assert "0035_concierge_details_jsonb" in result.stdout + assert result.stdout.count('CREATE TABLE "AdminRole"') == 1 + assert result.stdout.count('CREATE TABLE "MediaAsset"') == 1 + assert 'ALTER TABLE "HomeWildArchive" ADD COLUMN images' not in result.stdout + assert 'ALTER TABLE "HomeTeamBuilding" ADD COLUMN "detailSubtitle"' not in result.stdout + assert 'ALTER TABLE "DetailRecord" ADD COLUMN "conciergeAdvisorId"' not in result.stdout + assert 'ALTER TABLE "Lead" ADD COLUMN "leadType"' not in result.stdout + + +def test_admin_ownership_skips_missing_and_already_owned_tables(monkeypatch): + migration = load_migration("0026_admin_ownership.py") + inspector = CurrentSchemaInspector() + calls = [] + + monkeypatch.setattr(migration, "is_offline_mode", lambda: False, raising=False) + monkeypatch.setattr(migration, "inspect", lambda _bind: inspector, raising=False) + monkeypatch.setattr(migration.op, "get_bind", lambda: ExistingBind()) + for operation in ( + "add_column", + "create_index", + "create_foreign_key", + "execute", + "alter_column", + ): + monkeypatch.setattr( + migration.op, + operation, + lambda *args, _operation=operation, **_kwargs: calls.append( + (_operation, args[0] if args else None) + ), + ) + + migration.upgrade() + + assert calls == [] diff --git a/WonderQ-MiniAPP/src/content.ts b/WonderQ-MiniAPP/src/content.ts index 0dc09e0..159d2ee 100644 --- a/WonderQ-MiniAPP/src/content.ts +++ b/WonderQ-MiniAPP/src/content.ts @@ -1,4 +1,4 @@ -const remoteGuizhouAssets: Record = { +export const remoteGuizhouAssets: Record = { "jiaxiu-tower": "https://dimg04.c-ctrip.com/images/1me6m12000rntioi4FFB2.jpg?proc=source%2Ftripcommunity", "xijiang-miao-village": @@ -9,8 +9,20 @@ const remoteGuizhouAssets: Record = { "https://news.cgtn.com/news/2023-07-02/Picturesque-landscapes-draw-tourists-to-Guizhou--1l6RJMNVStq/img/f0d32df3426e4b70bf7a8b00a3404ef9/f0d32df3426e4b70bf7a8b00a3404ef9.jpeg", }; +export function resolveNetworkImage(image: string | null | undefined, fallbackImage: string) { + const normalized = typeof image === "string" ? image.trim() : ""; + if (!normalized) return fallbackImage; + + const localAssetMatch = normalized.match(/^\/?assets\/guizhou\/([^/?#]+?)(?:\.[^/?#]+)?$/i); + if (localAssetMatch) { + return remoteGuizhouAssets[localAssetMatch[1]] ?? fallbackImage; + } + + return normalized.startsWith("/assets/") ? fallbackImage : normalized; +} + const guizhouAsset = (name: string) => - remoteGuizhouAssets[name] ?? `/assets/guizhou/${name}.jpg`; + remoteGuizhouAssets[name] ?? remoteGuizhouAssets["huangguoshu-waterfall"]; export const heroSlides = [ { diff --git a/WonderQ-MiniAPP/src/lib/data.ts b/WonderQ-MiniAPP/src/lib/data.ts index 1b5585a..f98043b 100644 --- a/WonderQ-MiniAPP/src/lib/data.ts +++ b/WonderQ-MiniAPP/src/lib/data.ts @@ -1,5 +1,6 @@ import { heroSlides as fallbackHeroSlides, + resolveNetworkImage, vehicleOptions as fallbackVehicleOptions, } from "@/content"; import type { @@ -26,7 +27,7 @@ export function normalizeHomeCards ({ + ? siteConfig.heroSlides.map((slide, index) => ({ + ...slide, + image: resolveNetworkImage( + slide.image, + fallback.heroSlides[index]?.image ?? fallback.heroSlides[0]?.image ?? "", + ), id: slide.id, - image: slide.image, kicker: slide.kicker, title: slide.title, + actionLabel: slide.actionLabel, targetType: slide.targetType, targetValue: slide.targetValue, })) diff --git a/WonderQ-MiniAPP/src/lib/types.ts b/WonderQ-MiniAPP/src/lib/types.ts index dcf06b7..4a3b7e4 100644 --- a/WonderQ-MiniAPP/src/lib/types.ts +++ b/WonderQ-MiniAPP/src/lib/types.ts @@ -28,6 +28,7 @@ export type PublicHeroSlide = { id: string; title: string; kicker?: string | null; + actionLabel?: string | null; image: string; targetType?: string | null; targetValue?: string | null; @@ -181,7 +182,16 @@ export type VehicleLeadPayload = Omit - + - + + + + + {{ slide.kicker }} + + + {{ slide.title }} + + + {{ slide.actionLabel || slide.action }} + + + import { ref } from "vue"; +import { fallbackData } from "@/lib/data"; +import { resolveNetworkImage } from "@/content"; import type { HeroSlide } from "@/lib/types"; -defineProps<{ +const props = withDefaults(defineProps<{ slides: HeroSlide[]; -}>(); + fallbackSlides?: HeroSlide[]; +}>(), { + fallbackSlides: () => fallbackData.heroSlides, +}); defineEmits<{ select: [title: string]; }>(); const heroIndex = ref(0); +const failedSlideImages = ref>({}); + +function getSlideKey(slide: HeroSlide, index: number) { + return slide.id || `${slide.image}-${index}`; +} + +function getFallbackImage(slide: HeroSlide, index: number) { + const fallbackSlide = props.fallbackSlides.find((item) => item.title === slide.title) ?? props.fallbackSlides[index]; + return fallbackSlide?.image ?? ""; +} + +function getSlideImage(slide: HeroSlide, index: number) { + const fallbackImage = getFallbackImage(slide, index); + return failedSlideImages.value[getSlideKey(slide, index)] ? fallbackImage : resolveNetworkImage(slide.image, fallbackImage); +} + +function onImageError(slide: HeroSlide, index: number) { + const fallbackImage = getFallbackImage(slide, index); + if (!fallbackImage || fallbackImage === slide.image) return; + failedSlideImages.value[getSlideKey(slide, index)] = true; +} function onSlideChange(event: { detail: { current: number } }) { heroIndex.value = Number(event.detail.current); diff --git a/WonderQ-MiniAPP/tests/data.test.ts b/WonderQ-MiniAPP/tests/data.test.ts index 6b6fb48..80ed028 100644 --- a/WonderQ-MiniAPP/tests/data.test.ts +++ b/WonderQ-MiniAPP/tests/data.test.ts @@ -50,6 +50,61 @@ describe("WonderQ shared data helpers", () => { expect(data.vehicleService.introTitle).toBe("旅游出行专属用车服务"); }); + it("keeps the backend hero actionLabel when normalizing site config", () => { + const config: PublicSiteConfig = { + heroSlides: [ + { + id: "hero-api", + title: "经典人文打卡线路", + kicker: "经典人文", + actionLabel: "查看线路", + image: "https://cdn.example.test/hero.webp", + targetType: null, + targetValue: null, + }, + ], + }; + + const data = normalizeSiteConfig(config, fallbackData); + + expect(data.heroSlides[0]).toMatchObject({ + id: "hero-api", + title: "经典人文打卡线路", + kicker: "经典人文", + actionLabel: "查看线路", + image: "https://cdn.example.test/hero.webp", + }); + }); + + it("replaces root-relative API images with network fallbacks", () => { + const config: PublicSiteConfig = { + heroSlides: [ + { + id: "hero-local-path", + title: "山野户外", + image: "/assets/guizhou/wanfenglin.jpg", + }, + ], + vehicleOptions: [ + { + id: "vehicle-local-path", + title: "舒适型用车", + description: "适合家庭出行", + image: "/assets/guizhou/jiaxiu-tower.jpg", + }, + ], + }; + + const data = normalizeSiteConfig(config, fallbackData); + + expect(data.heroSlides[0]?.image).toBe( + "https://wanfenglin-guanwang.oss-cn-chengdu.aliyuncs.com/uploads/20240718/23ea4291a9570b94a1f8df9a012c18ec.jpg", + ); + expect(data.vehicleOptions[0]?.image).toBe( + "https://dimg04.c-ctrip.com/images/1me6m12000rntioi4FFB2.jpg?proc=source%2Ftripcommunity", + ); + }); + it("returns a stable submit error message", () => { expect(getSubmitErrorMessage(new Error("网络异常"))).toBe("网络异常"); expect(getSubmitErrorMessage("unknown")).toBe("提交失败,请稍后再试。");