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 @@
+
+
+
+
+
+
+
+
+ 推荐
+ A · 我的出行记录
+ 一个容器内分“用车需求”和“最近浏览”两种记录。语义最直观,改动小,适合当前页面。
+
+
+
11:14100% ▰
+
把旅程交给懂贵州的人
A QUIETER WAY TO TRAVEL
+
+
我的出行记录 2 条记录
+
用车需求与最近浏览集中在这里
+
+
+
+
+
+
+
+
+ B · 我的记录 + 筛选
+ 统一成一张记录列表,用“全部 / 用车需求 / 浏览记录”筛选。信息更规整,但需要增加筛选交互。
+
+
+
11:14100% ▰
+
把旅程交给懂贵州的人
A QUIETER WAY TO TRAVEL
+
+
我的记录 2 条记录
+
按类型查看你的出行足迹
+
+
+
+
+
+
+
+
+
+ C · 两个快捷入口 + 浏览列表
+ 合并成“我的旅程”模块,顶部放两个入口,下面保留历史浏览列表。入口识别快,但占用横向空间。
+
+
+
11:14100% ▰
+
把旅程交给懂贵州的人
A QUIETER WAY TO TRAVEL
+
+
我的旅程 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 @@
+
+
+
+
+
+
+
+
+ 推荐
+ A · 我的出行记录
+ 一个容器内分“用车需求”和“最近浏览”两种记录。语义最直观,改动小,适合当前页面。
+
+
+
11:14100% ▰
+
把旅程交给懂贵州的人
A QUIETER WAY TO TRAVEL
+
+
我的出行记录 2 条记录
+
用车需求与最近浏览集中在这里
+
+
+
+
+
+
+
+
+ B · 我的记录 + 筛选
+ 统一成一张记录列表,用“全部 / 用车需求 / 浏览记录”筛选。信息更规整,但需要增加筛选交互。
+
+
+
11:14100% ▰
+
把旅程交给懂贵州的人
A QUIETER WAY TO TRAVEL
+
+
我的记录 2 条记录
+
按类型查看你的出行足迹
+
+
+
+
+
+
+
+
+
+ C · 两个快捷入口 + 浏览列表
+ 合并成“我的旅程”模块,顶部放两个入口,下面保留历史浏览列表。入口识别快,但占用横向空间。
+
+
+
11:14100% ▰
+
把旅程交给懂贵州的人
A QUIETER WAY TO TRAVEL
+
+
我的旅程 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