Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -37,7 +37,7 @@ describe("operations client", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports filtered leads, media listing and status contracts", async () => {
|
||||
it("supports filtered leads, paginated media listing and status contracts", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(200, { items: [] }))
|
||||
.mockResolvedValueOnce(jsonResponse(200, { id: "lead-1", status: "contacted" }))
|
||||
@@ -46,12 +46,12 @@ describe("operations client", () => {
|
||||
|
||||
await getLeads({ leadType: "vehicle", status: "new", keyword: "贵州", take: 50 });
|
||||
await updateLeadStatus("lead-1", "contacted");
|
||||
await getMediaAssets();
|
||||
await getMediaAssets({ pageNum: 2, pageSize: 20 });
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([
|
||||
["/api/admin/leads?leadType=vehicle&status=new&keyword=%E8%B4%B5%E5%B7%9E&take=50", undefined],
|
||||
["/api/admin/leads/lead-1/status", "PATCH"],
|
||||
["/api/admin/media-assets", undefined],
|
||||
["/api/admin/media-assets?pageNum=2&pageSize=20", undefined],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
LeadStatus,
|
||||
LeadType,
|
||||
MediaAsset,
|
||||
PaginatedResult,
|
||||
SiteConfig,
|
||||
SiteConfigItem,
|
||||
SiteItemPatch,
|
||||
@@ -242,7 +243,13 @@ export const updateLeadStatus = (leadId: string, status: LeadStatus) =>
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
export const getMediaAssets = () => request<{ items: MediaAsset[] }>("/api/admin/media-assets");
|
||||
export const getMediaAssets = (params: { pageNum?: number; pageSize?: number } = {}) => {
|
||||
const query = new URLSearchParams({
|
||||
pageNum: String(params.pageNum ?? 1),
|
||||
pageSize: String(params.pageSize ?? 20),
|
||||
});
|
||||
return request<PaginatedResult<MediaAsset>>(`/api/admin/media-assets?${query.toString()}`);
|
||||
};
|
||||
export const getSystemResource = (resource: string) => request<{ items: unknown[] }>(`/api/admin/system/${resource}`);
|
||||
export const getSystemUsers = () => request<{ items: SystemUserItem[] }>("/api/admin/system/users");
|
||||
export const getSystemRoles = () => request<{ items: SystemRoleItem[] }>("/api/admin/system/roles");
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("./SidebarItem.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("侧栏菜单图标容器", () => {
|
||||
it("避免折叠菜单把 Tooltip 触发器误判为隐藏文本", () => {
|
||||
expect(source).toContain('<div class="sidebar-item__content">');
|
||||
expect(source).not.toContain('<span class="sidebar-item__content">');
|
||||
});
|
||||
});
|
||||
|
||||
describe("折叠态子菜单", () => {
|
||||
it("在弹出的子菜单中保留子项标题", () => {
|
||||
expect(source).toContain(':collapsed="false"');
|
||||
});
|
||||
});
|
||||
@@ -39,23 +39,23 @@ const childBasePath = computed(() => resolvedPath.value || props.basePath || "/"
|
||||
:aria-label="menu.name" :class="['sidebar-item', `sidebar-item--${navigationType}`]">
|
||||
<template #title>
|
||||
<el-tooltip :content="menu.name" :disabled="!collapsed" placement="right">
|
||||
<span class="sidebar-item__content">
|
||||
<div class="sidebar-item__content">
|
||||
<LayoutIcon :name="getMenuIconName(menu)" :title="collapsed ? menu.name : undefined" size="18" />
|
||||
<span v-if="!collapsed" class="sidebar-item__label">{{ menu.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<SidebarItem v-for="child in visibleChildren" :key="child.id" :menu="child" :base-path="childBasePath"
|
||||
:collapsed="collapsed" :navigation-type="navigationType" />
|
||||
:collapsed="false" :navigation-type="navigationType" />
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item v-else-if="menu.type === 'page' && resolvedPath" :index="resolvedPath" :aria-label="menu.name"
|
||||
:class="['sidebar-item', `sidebar-item--${navigationType}`]">
|
||||
<el-tooltip :content="menu.name" :disabled="!collapsed" placement="right">
|
||||
<span class="sidebar-item__content">
|
||||
<div class="sidebar-item__content">
|
||||
<LayoutIcon :name="getMenuIconName(menu)" :title="collapsed ? menu.name : undefined" size="18" />
|
||||
<span v-if="!collapsed" class="sidebar-item__label">{{ menu.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
15
WonderQ-Admin-UI-Vue/src/pages/LoginPage.test.ts
Normal file
15
WonderQ-Admin-UI-Vue/src/pages/LoginPage.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = readFileSync(new URL("./LoginPage.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("登录页凭据初始化", () => {
|
||||
it("不预填账号和密码,但保留记住密码的账号恢复逻辑", () => {
|
||||
expect(source).toContain("email: rememberedEmail, password: \"\"");
|
||||
expect(source).toContain("rememberMe: Boolean(rememberedEmail)");
|
||||
expect(source).toContain("window.localStorage.setItem(rememberedEmailKey, form.email.trim())");
|
||||
expect(source).toContain("window.localStorage.removeItem(rememberedEmailKey)");
|
||||
expect(source).not.toContain("admin@example.com");
|
||||
expect(source).not.toContain("ChangeMe123!");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const rememberedEmailKey = "wonderq-admin-remembered-email";
|
||||
const rememberedEmail = typeof window === "undefined" ? "" : window.localStorage.getItem(rememberedEmailKey) ?? "";
|
||||
const form = reactive({ email: rememberedEmail || "admin@example.com", password: "ChangeMe123!", captchaId: "", captchaCode: "", rememberMe: Boolean(rememberedEmail) });
|
||||
const form = reactive({ email: rememberedEmail, password: "", captchaId: "", captchaCode: "", rememberMe: Boolean(rememberedEmail) });
|
||||
const error = ref("");
|
||||
const captchaImage = ref("");
|
||||
const captchaLoading = ref(false);
|
||||
|
||||
14
WonderQ-Admin-UI-Vue/src/pages/MediaPage.test.ts
Normal file
14
WonderQ-Admin-UI-Vue/src/pages/MediaPage.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
|
||||
|
||||
describe("媒体素材卡片文字", () => {
|
||||
it("长标题和长 URL 应换行展示而不是被裁切", () => {
|
||||
const cardTextRule = styles.match(/\.media-card-content strong,[\s\S]*?\.media-card-content small \{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||||
|
||||
expect(cardTextRule).toContain("overflow-wrap: anywhere;");
|
||||
expect(cardTextRule).toContain("word-break: break-word;");
|
||||
expect(cardTextRule).toContain("white-space: normal;");
|
||||
});
|
||||
});
|
||||
@@ -12,13 +12,18 @@ const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const group = ref("general");
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const total = ref(0);
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
assets.value = (await getMediaAssets()).items;
|
||||
const result = await getMediaAssets({ pageNum: pageNum.value, pageSize: pageSize.value });
|
||||
assets.value = result.items;
|
||||
total.value = result.total;
|
||||
} catch (loadError) {
|
||||
error.value = loadError instanceof Error ? loadError.message : "媒体素材加载失败";
|
||||
} finally {
|
||||
@@ -42,7 +47,8 @@ async function upload(event: Event) {
|
||||
if (validationError) throw new Error(validationError);
|
||||
uploaded.push(await uploadMediaAsset(file, group.value.trim() || "general"));
|
||||
}
|
||||
assets.value = [...uploaded, ...assets.value];
|
||||
pageNum.value = 1;
|
||||
await load();
|
||||
ElMessage.success(`已上传 ${uploaded.length} 个素材`);
|
||||
} catch (uploadFailure) {
|
||||
uploadError.value = uploadFailure instanceof Error ? uploadFailure.message : "素材上传失败,请重试";
|
||||
@@ -51,16 +57,54 @@ async function upload(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(value: number) {
|
||||
pageNum.value = value;
|
||||
void load();
|
||||
}
|
||||
|
||||
function changePageSize(value: number) {
|
||||
pageSize.value = value;
|
||||
pageNum.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-intro site-config-intro"><div><p class="eyebrow">LIBRARY / MEDIA</p><h2>媒体素材</h2><p>统一查看已上传图片,并为站点、玩法详情和管家资料提供可复用 URL。</p></div><div class="media-toolbar"><el-input v-model="group" aria-label="素材分组" placeholder="素材分组" /><el-button v-permission="'admin:media:read'" type="primary" :loading="uploading" @click="openPicker">上传素材</el-button></div></div>
|
||||
<input ref="input" class="media-file-input" type="file" multiple accept="image/jpeg,image/png,image/webp,image/gif" :disabled="uploading" @change="upload" />
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button link type="primary" @click="load">重试</el-button></template></el-alert>
|
||||
<div class="page-intro site-config-intro">
|
||||
<div>
|
||||
<p class="eyebrow">LIBRARY / MEDIA</p>
|
||||
<h2>媒体素材</h2>
|
||||
<p>统一查看已上传图片,并为站点、玩法详情和管家资料提供可复用 URL。</p>
|
||||
</div>
|
||||
<div class="media-toolbar"><el-input v-model="group" aria-label="素材分组" placeholder="素材分组" /><el-button
|
||||
v-permission="'admin:media:read'" type="primary" :loading="uploading" @click="openPicker">上传素材</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="input" class="media-file-input" type="file" multiple accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
:disabled="uploading" @change="upload" />
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button link
|
||||
type="primary" @click="load">重试</el-button></template></el-alert>
|
||||
<el-alert v-if="uploadError" :title="uploadError" type="error" show-icon :closable="false" />
|
||||
<el-skeleton v-if="loading" :rows="8" animated />
|
||||
<el-card v-else shadow="never" class="panel-card"><el-empty v-if="!assets.length" description="暂无媒体素材"><el-button v-permission="'admin:media:read'" type="primary" :loading="uploading" @click="openPicker">上传第一张图片</el-button></el-empty><div v-else class="media-grid"><article v-for="asset in assets" :key="asset.id" class="media-card"><el-image :src="resolveAdminMediaUrl(asset.url)" fit="cover" :preview-src-list="[resolveAdminMediaUrl(asset.url) ?? asset.url]" /><div class="media-card-content"><strong>{{ asset.name || "未命名素材" }}</strong><span>{{ asset.group || "general" }} · {{ formatMediaSize(asset.sizeBytes) }}</span><small>{{ asset.url }}</small></div></article></div></el-card>
|
||||
<el-card v-else shadow="never" class="panel-card">
|
||||
<el-empty v-if="!assets.length" description="暂无媒体素材"><el-button v-permission="'admin:media:read'" type="primary"
|
||||
:loading="uploading" @click="openPicker">上传第一张图片</el-button></el-empty>
|
||||
<template v-else>
|
||||
<div class="media-grid">
|
||||
<article v-for="asset in assets" :key="asset.id" class="media-card"><el-image
|
||||
:src="resolveAdminMediaUrl(asset.url)" fit="cover"
|
||||
:preview-src-list="[resolveAdminMediaUrl(asset.url) ?? asset.url]" />
|
||||
<div class="media-card-content"><strong>{{ asset.name || "未命名素材" }}</strong><span>{{ asset.group ||
|
||||
"general" }} · {{ formatMediaSize(asset.sizeBytes) }}</span><small>{{ asset.url }}</small></div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="media-pagination"><el-pagination v-model:current-page="pageNum" v-model:page-size="pageSize"
|
||||
:page-sizes="[12, 20, 40]" :total="total" background layout="total, sizes, prev, pager, next"
|
||||
@current-change="changePage" @size-change="changePageSize" /></div>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -403,6 +403,11 @@ input {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.media-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.media-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
@@ -420,13 +425,23 @@ input {
|
||||
gap: 5px;
|
||||
padding: 10px;
|
||||
}
|
||||
.media-card-content strong,
|
||||
.media-card-content span,
|
||||
.media-card-content small {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
.media-card-content strong {
|
||||
color: var(--ink);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.media-card-content span,
|
||||
.media-card-content small {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.media-card-content small {
|
||||
color: #94a3b8;
|
||||
|
||||
@@ -225,6 +225,13 @@ export type MediaAsset = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type PaginatedResult<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type ConciergeAdvisor = {
|
||||
id: string;
|
||||
avatar?: string;
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig(({ mode }) => {
|
||||
base: "/admin/",
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
alias: { "@": path.resolve(import.meta.dirname, "./src") },
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
|
||||
@@ -1500,9 +1500,28 @@ def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user
|
||||
|
||||
|
||||
@router.get("/media-assets")
|
||||
def list_media_assets(_user: AdminUser = Depends(require_admin_permission("admin:media:read")), db: Session = Depends(get_db)):
|
||||
assets = db.scalars(apply_data_scope(select(MediaAsset), MediaAsset, _user, db).order_by(MediaAsset.createdAt.desc()).limit(200)).all()
|
||||
return success_response({"items": [media_model_dict(asset) for asset in assets]})
|
||||
def list_media_assets(
|
||||
pageNum: int = Query(default=1, ge=1),
|
||||
pageSize: int = Query(default=20, ge=1, le=200),
|
||||
_user: AdminUser = Depends(require_admin_permission("admin:media:read")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
offset = (pageNum - 1) * pageSize
|
||||
assets_stmt = (
|
||||
select(MediaAsset)
|
||||
.order_by(MediaAsset.createdAt.desc())
|
||||
.offset(offset)
|
||||
.limit(pageSize)
|
||||
)
|
||||
assets = db.scalars(apply_data_scope(assets_stmt, MediaAsset, _user, db)).all()
|
||||
total_stmt = apply_data_scope(select(func.count()).select_from(MediaAsset), MediaAsset, _user, db)
|
||||
total = db.scalar(total_stmt) or 0
|
||||
return success_response({
|
||||
"items": [media_model_dict(asset) for asset in assets],
|
||||
"total": total,
|
||||
"pageNum": pageNum,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/media-assets/upload", status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -590,6 +590,39 @@ def test_admin_media_upload_rejects_non_image_file():
|
||||
assert not fake_db.committed
|
||||
|
||||
|
||||
def test_admin_media_assets_endpoint_returns_paginated_results():
|
||||
assets = [
|
||||
MediaAsset(
|
||||
id="asset-1",
|
||||
url="https://cdn.example.test/asset-1.jpg",
|
||||
name="asset-1.jpg",
|
||||
mimeType="image/jpeg",
|
||||
sizeBytes=1024,
|
||||
group="general",
|
||||
createdAt=datetime(2026, 1, 2),
|
||||
updatedAt=datetime(2026, 1, 2),
|
||||
)
|
||||
]
|
||||
fake_db = FakeDb(scalar_results=[assets], scalar_values=[3])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
try:
|
||||
response = TestClient(app).get(
|
||||
"/api/admin/media-assets",
|
||||
params={"pageNum": 2, "pageSize": 1},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()["data"]
|
||||
assert body["items"][0]["id"] == "asset-1"
|
||||
assert body["total"] == 3
|
||||
assert body["pageNum"] == 2
|
||||
assert body["pageSize"] == 1
|
||||
assert len(fake_db.scalar_statements) == 2
|
||||
|
||||
|
||||
def test_admin_leads_endpoint_accepts_source_keyword_and_created_range_filters():
|
||||
fake_db = FakeDb(scalar_results=[[]])
|
||||
app = authenticated_app(fake_db)
|
||||
|
||||
@@ -9,7 +9,7 @@ const uni =
|
||||
: (uniModule as { default: typeof uniModule }).default;
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, "VITE_");
|
||||
const env = loadEnv(mode, import.meta.dirname, "VITE_");
|
||||
const apiBaseUrl = env.VITE_API_BASE_URL?.trim() ?? "";
|
||||
|
||||
return {
|
||||
@@ -18,7 +18,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
"@": path.resolve(import.meta.dirname, "src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
@@ -36,7 +36,7 @@ export default defineConfig(({ mode }) => {
|
||||
cssOptions: {
|
||||
rem2rpx: true,
|
||||
},
|
||||
cssEntries: [path.resolve(__dirname, "src/app.css")],
|
||||
cssEntries: [path.resolve(import.meta.dirname, "src/app.css")],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
"@": path.resolve(import.meta.dirname, "src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
@@ -75,9 +75,11 @@
|
||||
| `PATCH` | `/api/admin/home/wild-archives/reorder` | 排序首页极境视界 |
|
||||
| `GET` | `/api/admin/leads` | 线索列表 |
|
||||
| `PATCH` | `/api/admin/leads/{id}/status` | 更新线索状态 |
|
||||
| `GET` | `/api/admin/media-assets` | 素材列表 |
|
||||
| `GET` | `/api/admin/media-assets` | 分页查询素材列表 |
|
||||
| `POST` | `/api/admin/media-assets/upload` | 上传图片 |
|
||||
|
||||
`GET /api/admin/media-assets?pageNum=1&pageSize=20` 按创建时间倒序分页返回素材,`pageSize` 最大为 `200`,响应为 `data: { items, total, pageNum, pageSize }`。管理端翻页时必须重新请求接口,不在前端截取固定数量的素材。
|
||||
|
||||
媒体上传成功响应中的 `data.url` 是可直接用于图片回显的 HTTP(S) URL。OSS 私有读场景下,该 URL 会包含短时 GET 签名;管理端应直接使用返回值,后续 Admin/Public API 响应会重新生成签名。该接口只写入素材库,不会自动绑定首页配置;绑定首页轮播或用车卡片后,仍需提交对应的站点配置保存接口。
|
||||
|
||||
## 站点模块
|
||||
|
||||
@@ -84,6 +84,7 @@ yarn dev:mp-weixin
|
||||
- 管理端界面图标统一由 `@element-plus/icons-vue` 提供;菜单返回的图标名经 `LayoutIcon` 白名单映射,未知值显示默认图标。顾问服务详情中的 `icon` 字段仍按业务内容数据处理。
|
||||
- 变更角色、菜单或用户关联后,权限缓存失效时要重新登录或重新加载菜单,确认侧栏、路由和按钮权限同步变化。
|
||||
- 站点模块、玩法、详情、管家、线索和媒体接口按当前契约返回。
|
||||
- “媒体素材”页面通过 `GET /api/admin/media-assets?pageNum=1&pageSize=20` 分页加载,翻页和切换每页条数都应重新请求后端,确认列表总数与服务端一致。
|
||||
- 新增、更新、删除和排序成功后,页面使用接口返回的数据更新状态,不自行生成 ID 或排序结果。
|
||||
|
||||
MiniAPP 重点检查:
|
||||
|
||||
Reference in New Issue
Block a user