1 changed files with 472 additions and 0 deletions
@ -0,0 +1,472 @@ |
|||||
|
# 媒体库与 `/me/media` 壳 Implementation Plan |
||||
|
|
||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
||||
|
|
||||
|
**Goal:** 按 `docs/superpowers/specs/2026-04-19-media-library-design.md` 实现 **`GET /api/me/media/assets`**、**`/me/media` 父壳 + 资源库子页**、将 **孤儿页** 纳入同一壳;更新 **`AppShell`** 与 **`/me` 控制台首页** 导航。 |
||||
|
|
||||
|
**Architecture:** 列表数据在 `server/service/media` 中新增「按用户分页列出 `media_assets` + 批量聚合 `media_refs` 计数」函数(两阶段查询,避免复杂 join)。Nuxt 使用 **`app/pages/me/media.vue`** 包裹 **`<NuxtPage />`**,子路由 **`media/index.vue`**(资源库)、**`media/orphans.vue`**(现有页面逻辑迁移路径不变)。前端复制使用 **`window.location.origin + '/public/assets/' + storageKey`**。上传复用 **`POST /api/file/upload`** 与资料页相同的 `fetchData` + `FormData` 模式。 |
||||
|
|
||||
|
**Tech Stack:** Nuxt 4.4、Nitro、`#server/service/media`(Drizzle + `dbGlobal`)、`mediaAssets` / `mediaRefs`、`defineWrappedResponseHandler`、`R.success`、`event.context.auth.requireUser()`、Bun test(`bun:test`)、Nuxt UI(`UContainer`、`UCard`、`UPagination`、`UButton`、`UFileUpload` 或隐藏 file input,与现有页面风格一致)。 |
||||
|
|
||||
|
**Spec:** `docs/superpowers/specs/2026-04-19-media-library-design.md` |
||||
|
|
||||
|
**验证:** 每任务完成后执行 `bun run build`;单测用 `bun test <路径>`。无全站 HTTP 集成测试框架,**401 / 用户隔离**以代码审查 + 手测登录态为主。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 文件结构(将创建 / 修改) |
||||
|
|
||||
|
| 路径 | 职责 | |
||||
|
|------|------| |
||||
|
| `server/utils/me-media-assets-query.ts` | 解析 `page`、`pageSize`(仅允许 10 / 20 / 50);供 API 与单测使用 | |
||||
|
| `server/utils/me-media-assets-query.test.ts` | 上述解析函数的单元测试 | |
||||
|
| `server/service/media/index.ts` | 新增 `listUserMediaAssetsPage(userId, page, pageSize)`:总数 + 当前页行 + 每行 `refCount` | |
||||
|
| `server/api/me/media/assets.get.ts` | 鉴权、query 解析、调用 service、返回 `R.success({ items, total })` | |
||||
|
| `app/pages/me/media.vue` | 「媒体」壳:标题、子导航(资源库 / 孤儿清理)、`<NuxtPage />` | |
||||
|
| `app/pages/me/media/index.vue` | 资源库:上传、网格、分页、复制 URL / Markdown、空态与错误 toast | |
||||
|
| `app/pages/me/media/orphans.vue` | **仅必要时**调整 `definePageMeta` / 布局相关(业务与 API 尽量不变) | |
||||
|
| `app/components/AppShell.vue` | 控制台子导航增加「媒体」;更新「四个子链接」类注释 | |
||||
|
| `app/pages/me/index.vue` | 合并「文章媒体清理」为单一「媒体」卡片,指向 `/me/media` | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 1: 解析 `page` / `pageSize` 工具与单测 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/utils/me-media-assets-query.ts` |
||||
|
- Create: `server/utils/me-media-assets-query.test.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 写失败单测(期望函数尚不存在)** |
||||
|
|
||||
|
在 `server/utils/me-media-assets-query.test.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
import { describe, expect, test } from "bun:test"; |
||||
|
import { parseMeMediaAssetsQuery } from "./me-media-assets-query"; |
||||
|
|
||||
|
describe("parseMeMediaAssetsQuery", () => { |
||||
|
test("defaults page to 1 and pageSize to 20", () => { |
||||
|
expect(parseMeMediaAssetsQuery({})).toEqual({ page: 1, pageSize: 20 }); |
||||
|
expect(parseMeMediaAssetsQuery({ page: undefined, pageSize: undefined })).toEqual({ |
||||
|
page: 1, |
||||
|
pageSize: 20, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test("accepts pageSize 10 20 50", () => { |
||||
|
expect(parseMeMediaAssetsQuery({ pageSize: "10" })).toEqual({ page: 1, pageSize: 10 }); |
||||
|
expect(parseMeMediaAssetsQuery({ pageSize: "50" })).toEqual({ page: 1, pageSize: 50 }); |
||||
|
}); |
||||
|
|
||||
|
test("throws 400 statusMessage when pageSize is not allowed", () => { |
||||
|
expect(() => parseMeMediaAssetsQuery({ pageSize: "15" })).toThrow(); |
||||
|
try { |
||||
|
parseMeMediaAssetsQuery({ pageSize: "99" }); |
||||
|
} catch (e) { |
||||
|
expect(e).toMatchObject({ statusCode: 400 }); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 运行单测确认失败** |
||||
|
|
||||
|
Run: `bun test server/utils/me-media-assets-query.test.ts` |
||||
|
|
||||
|
Expected: FAIL(模块或导出不存在) |
||||
|
|
||||
|
- [ ] **Step 3: 实现 `parseMeMediaAssetsQuery`** |
||||
|
|
||||
|
新建 `server/utils/me-media-assets-query.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
const ALLOWED_PAGE_SIZES = new Set([10, 20, 50]); |
||||
|
|
||||
|
function parsePositiveInt(raw: string | undefined, fallback: number, label: string): number { |
||||
|
if (raw === undefined || raw === "") { |
||||
|
return fallback; |
||||
|
} |
||||
|
const n = Number(raw); |
||||
|
if (!Number.isInteger(n) || n < 1) { |
||||
|
throw createError({ statusCode: 400, statusMessage: `${label} 须为正整数` }); |
||||
|
} |
||||
|
return n; |
||||
|
} |
||||
|
|
||||
|
export type MeMediaAssetsQuery = { |
||||
|
page: number; |
||||
|
pageSize: number; |
||||
|
}; |
||||
|
|
||||
|
/** 从 `getQuery(event)` 的字符串值解析;pageSize 仅允许 10 / 20 / 50。 */ |
||||
|
export function parseMeMediaAssetsQuery(q: { |
||||
|
page?: string; |
||||
|
pageSize?: string; |
||||
|
}): MeMediaAssetsQuery { |
||||
|
const page = parsePositiveInt(q.page, 1, "page"); |
||||
|
const pageSizeRaw = parsePositiveInt(q.pageSize, 20, "pageSize"); |
||||
|
if (!ALLOWED_PAGE_SIZES.has(pageSizeRaw)) { |
||||
|
throw createError({ |
||||
|
statusCode: 400, |
||||
|
statusMessage: "pageSize 须为 10、20 或 50", |
||||
|
}); |
||||
|
} |
||||
|
return { page, pageSize: pageSizeRaw }; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 4: 运行单测确认通过** |
||||
|
|
||||
|
Run: `bun test server/utils/me-media-assets-query.test.ts` |
||||
|
|
||||
|
Expected: PASS |
||||
|
|
||||
|
- [ ] **Step 5: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/utils/me-media-assets-query.ts server/utils/me-media-assets-query.test.ts |
||||
|
git commit -m "feat(media): add me media assets query parser and tests" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 2: Service `listUserMediaAssetsPage` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `server/service/media/index.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 在 `server/service/media/index.ts` 追加导出函数** |
||||
|
|
||||
|
在文件末尾(或与其他 list 函数邻近)新增(注意与现有 `import` 一致,已存在则复用 `count`、`desc`、`eq`、`inArray` 等): |
||||
|
|
||||
|
```typescript |
||||
|
export type UserMediaAssetListRow = { |
||||
|
id: number; |
||||
|
storageKey: string; |
||||
|
mime: string; |
||||
|
sizeBytes: number; |
||||
|
createdAt: Date; |
||||
|
refCount: number; |
||||
|
}; |
||||
|
|
||||
|
export async function listUserMediaAssetsPage( |
||||
|
userId: number, |
||||
|
page: number, |
||||
|
pageSize: number, |
||||
|
): Promise<{ items: UserMediaAssetListRow[]; total: number }> { |
||||
|
const offset = (page - 1) * pageSize; |
||||
|
|
||||
|
const [{ total }] = await dbGlobal |
||||
|
.select({ total: count() }) |
||||
|
.from(mediaAssets) |
||||
|
.where(eq(mediaAssets.userId, userId)); |
||||
|
|
||||
|
const rows = await dbGlobal |
||||
|
.select({ |
||||
|
id: mediaAssets.id, |
||||
|
storageKey: mediaAssets.storageKey, |
||||
|
mime: mediaAssets.mime, |
||||
|
sizeBytes: mediaAssets.sizeBytes, |
||||
|
createdAt: mediaAssets.createdAt, |
||||
|
}) |
||||
|
.from(mediaAssets) |
||||
|
.where(eq(mediaAssets.userId, userId)) |
||||
|
.orderBy(desc(mediaAssets.createdAt)) |
||||
|
.limit(pageSize) |
||||
|
.offset(offset); |
||||
|
|
||||
|
if (rows.length === 0) { |
||||
|
return { items: [], total }; |
||||
|
} |
||||
|
|
||||
|
const ids = rows.map((r) => r.id); |
||||
|
const refRows = await dbGlobal |
||||
|
.select({ |
||||
|
assetId: mediaRefs.assetId, |
||||
|
c: count(), |
||||
|
}) |
||||
|
.from(mediaRefs) |
||||
|
.where(inArray(mediaRefs.assetId, ids)) |
||||
|
.groupBy(mediaRefs.assetId); |
||||
|
|
||||
|
const refMap = new Map(refRows.map((r) => [r.assetId, r.c])); |
||||
|
|
||||
|
const items: UserMediaAssetListRow[] = rows.map((r) => ({ |
||||
|
id: r.id, |
||||
|
storageKey: r.storageKey, |
||||
|
mime: r.mime, |
||||
|
sizeBytes: r.sizeBytes, |
||||
|
createdAt: r.createdAt, |
||||
|
refCount: refMap.get(r.id) ?? 0, |
||||
|
})); |
||||
|
|
||||
|
return { items, total }; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
确保文件顶部已从 `drizzle-orm` 导入 `count`、`desc`、`eq`、`inArray`(若无 `inArray` 则追加)。 |
||||
|
|
||||
|
- [ ] **Step 2: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/service/media/index.ts |
||||
|
git commit -m "feat(media): add listUserMediaAssetsPage with ref counts" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 3: API `GET /api/me/media/assets` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `server/api/me/media/assets.get.ts` |
||||
|
|
||||
|
- [ ] **Step 1: 实现 handler** |
||||
|
|
||||
|
`server/api/me/media/assets.get.ts`: |
||||
|
|
||||
|
```typescript |
||||
|
import { listUserMediaAssetsPage } from "#server/service/media"; |
||||
|
import { parseMeMediaAssetsQuery } from "#server/utils/me-media-assets-query"; |
||||
|
|
||||
|
export default defineWrappedResponseHandler(async (event) => { |
||||
|
const user = await event.context.auth.requireUser(); |
||||
|
const q = getQuery(event); |
||||
|
const { page, pageSize } = parseMeMediaAssetsQuery({ |
||||
|
page: typeof q.page === "string" ? q.page : undefined, |
||||
|
pageSize: typeof q.pageSize === "string" ? q.pageSize : undefined, |
||||
|
}); |
||||
|
|
||||
|
const { items, total } = await listUserMediaAssetsPage(user.id, page, pageSize); |
||||
|
|
||||
|
const payload = { |
||||
|
items: items.map((r) => ({ |
||||
|
id: r.id, |
||||
|
storageKey: r.storageKey, |
||||
|
publicPath: `/public/assets/${r.storageKey}`, |
||||
|
mime: r.mime, |
||||
|
sizeBytes: r.sizeBytes, |
||||
|
createdAt: r.createdAt.toISOString(), |
||||
|
refCount: r.refCount, |
||||
|
})), |
||||
|
total, |
||||
|
}; |
||||
|
|
||||
|
return R.success(payload); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
- [ ] **Step 2: 手测(开发服务器)** |
||||
|
|
||||
|
Run: `bun run dev`,登录后请求 `GET /api/me/media/assets?page=1&pageSize=20`(浏览器或 curl 带 cookie)。 |
||||
|
|
||||
|
Expected: `code === 0`,`data.items` 为数组;未登录应 **401**(与现有 `/api/me/*` 一致)。 |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add server/api/me/media/assets.get.ts |
||||
|
git commit -m "feat(api): add GET /api/me/media/assets for media library" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 4: 父壳 `app/pages/me/media.vue` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `app/pages/me/media.vue` |
||||
|
|
||||
|
- [ ] **Step 1: 实现壳组件** |
||||
|
|
||||
|
`app/pages/me/media.vue`(子页使用 `definePageMeta` 设标题;壳可用 `useRoute` 高亮 tab;导航用 `NuxtLink` 或 `UButton` `to` 保持与项目风格一致): |
||||
|
|
||||
|
```vue |
||||
|
<script setup lang="ts"> |
||||
|
const route = useRoute() |
||||
|
|
||||
|
const tabs = [ |
||||
|
{ label: '资源库', to: '/me/media' }, |
||||
|
{ label: '孤儿清理', to: '/me/media/orphans' }, |
||||
|
] as const |
||||
|
|
||||
|
function tabActive(to: string) { |
||||
|
if (to === '/me/media') { |
||||
|
return route.path === '/me/media' || route.path === '/me/media/' |
||||
|
} |
||||
|
return route.path === to || route.path.startsWith(`${to}/`) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<UContainer class="py-8 space-y-6"> |
||||
|
<div> |
||||
|
<h1 class="text-2xl font-semibold"> |
||||
|
媒体 |
||||
|
</h1> |
||||
|
<p class="text-sm text-muted mt-1"> |
||||
|
上传与管理图片;孤儿资源请在「孤儿清理」中处理。 |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="flex flex-wrap gap-2 border-b border-default pb-3"> |
||||
|
<UButton |
||||
|
v-for="t in tabs" |
||||
|
:key="t.to" |
||||
|
:to="t.to" |
||||
|
size="sm" |
||||
|
:variant="tabActive(t.to) ? 'solid' : 'ghost'" |
||||
|
:color="tabActive(t.to) ? 'primary' : 'neutral'" |
||||
|
> |
||||
|
{{ t.label }} |
||||
|
</UButton> |
||||
|
</div> |
||||
|
|
||||
|
<NuxtPage /> |
||||
|
</UContainer> |
||||
|
</template> |
||||
|
``` |
||||
|
|
||||
|
若项目里 `UButton` 的 `to` 与 `variant` 组合有既定用法,以对齐 **`orphans.vue` / `AppShell`** 为准微调。 |
||||
|
|
||||
|
- [ ] **Step 2: `bun run build`** |
||||
|
|
||||
|
Expected: 构建成功。 |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add app/pages/me/media.vue |
||||
|
git commit -m "feat(ui): add /me/media parent shell with sub nav" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 5: 资源库页 `app/pages/me/media/index.vue` |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Create: `app/pages/me/media/index.vue` |
||||
|
|
||||
|
- [ ] **Step 1: 实现页面** |
||||
|
|
||||
|
要点(与 `app/pages/me/media/orphans.vue` 对齐模式:`useToast`、`useClientApi`、`useAuthSession` 按需): |
||||
|
|
||||
|
- `definePageMeta({ title: '媒体库' })` |
||||
|
- state:`page`、`pageSize`、`items`、`total`、`loading`、`uploading` |
||||
|
- `load()`:`fetchData` 请求 **`/api/me/media/assets?page=${page}&pageSize=${pageSize}`**,类型与 API `payload` 一致 |
||||
|
- 模板:顶部 **`UFileUpload`** 或 `<input type="file" multiple accept="image/png,image/jpeg,image/jpg,image/webp">` + 上传按钮;`FormData` 字段名 **`file`**(与 `upload.post.ts` 的 `upload.array('file', 10)` 一致),`POST /api/file/upload` |
||||
|
- 网格:`grid gap-4 sm:grid-cols-2 md:grid-cols-3` 等;每张卡片 `<img :src="item.publicPath" :alt="item.storageKey" class="..." loading="lazy">`(`publicPath` 为相对路径即可用于 `img src`) |
||||
|
- 文案:`formatBytes` / `formatDt` 可从 `orphans.vue` 复制小函数,避免新依赖 |
||||
|
- **复制**:`const absoluteUrl = `${window.location.origin}${item.publicPath}`;`navigator.clipboard.writeText`;Markdown 为 `` `` ``;成功/失败 `toast.add` |
||||
|
- 展示 **`refCount`**(小字「引用数:n」) |
||||
|
- **`UPagination`**:`total`、`page-size` 绑定;`pageSize` 变更时重置 `page` 为 1 并 `load` |
||||
|
- 空列表:说明 + 上传引导 |
||||
|
|
||||
|
- [ ] **Step 2: `bun run build`** |
||||
|
|
||||
|
Expected: PASS |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add app/pages/me/media/index.vue |
||||
|
git commit -m "feat(ui): add media library page at /me/media" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 6: 孤儿页与壳的协同 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `app/pages/me/media/orphans.vue`(按需) |
||||
|
|
||||
|
- [ ] **Step 1: 检查布局重复** |
||||
|
|
||||
|
打开 `/me/media/orphans`:若孤儿页根节点仍有 **`UContainer` + 大标题**,会与父壳重复。若存在: |
||||
|
|
||||
|
- 去掉孤儿页外层 **`UContainer` / 重复标题**(保留筛选、表格、弹窗等业务块),或改为仅 **卡片内** 标题为「图片孤儿审查」。 |
||||
|
|
||||
|
**不要**改动孤儿列表 API 路径与删除逻辑。 |
||||
|
|
||||
|
- [ ] **Step 2: `bun run build` + 手测两条路由** |
||||
|
|
||||
|
`/me/media`、`/me/media/orphans` 导航与高亮正确。 |
||||
|
|
||||
|
- [ ] **Step 3: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add app/pages/me/media/orphans.vue |
||||
|
git commit -m "fix(ui): avoid duplicate chrome on media orphans under shell" |
||||
|
``` |
||||
|
|
||||
|
若 Step 1 确认无需修改,可跳过 commit,在计划中勾选并注明「无重复容器,跳过」。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Task 7: `AppShell` 与 `/me` 首页 |
||||
|
|
||||
|
**Files:** |
||||
|
|
||||
|
- Modify: `app/components/AppShell.vue` |
||||
|
- Modify: `app/pages/me/index.vue` |
||||
|
|
||||
|
- [ ] **Step 1: `AppShell.vue`** |
||||
|
|
||||
|
在 `consoleSubNav`(及注释「桌面端:下拉内…」)中 **插入**: |
||||
|
|
||||
|
```typescript |
||||
|
{ label: '媒体', to: '/me/media', icon: 'i-lucide-images' }, |
||||
|
``` |
||||
|
|
||||
|
位置建议:在「RSS」之后或「文章」之后,与产品一致即可。同步 **`mobileMenuItems`** 里控制台分组数组。 |
||||
|
|
||||
|
- [ ] **Step 2: `app/pages/me/index.vue`** |
||||
|
|
||||
|
将原「文章媒体清理」卡片 **替换** 为一块 **「媒体」**: |
||||
|
|
||||
|
- 标题:**媒体** |
||||
|
- 描述:**资源库上传与复制链接;孤儿图片审查与清理。** |
||||
|
- 按钮:`to="/me/media"`,文案 **进入** |
||||
|
|
||||
|
删除单独指向 **`/me/media/orphans`** 的首页卡片(spec:单一入口)。 |
||||
|
|
||||
|
- [ ] **Step 3: `bun run build`** |
||||
|
|
||||
|
- [ ] **Step 4: Commit** |
||||
|
|
||||
|
```bash |
||||
|
git add app/components/AppShell.vue app/pages/me/index.vue |
||||
|
git commit -m "feat(nav): add media console link and merge dashboard card" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Spec 对照(自检) |
||||
|
|
||||
|
| Spec 章节 | 对应任务 | |
||||
|
|-----------|----------| |
||||
|
| 路由 `/me/media`、`/me/media/orphans` + 父壳 | Task 4、Task 6 | |
||||
|
| 资源库列表、分页、排序、refCount | Task 2、Task 5 | |
||||
|
| 上传 `POST /api/file/upload` | Task 5 | |
||||
|
| 复制 URL + Markdown(绝对 origin) | Task 5 | |
||||
|
| `GET /api/me/media/assets` 鉴权与 query | Task 1、Task 3 | |
||||
|
| AppShell + `/me` 卡片 | Task 7 | |
||||
|
| 首版不做库内删除、不做编辑器集成 | 计划中无对应任务(符合) | |
||||
|
|
||||
|
**占位符扫描:** 无 TBD。 |
||||
|
|
||||
|
**类型一致性:** API 返回字段名 `publicPath`、`refCount`、`createdAt`(ISO 字符串)与 Task 5 前端类型一致。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
**Plan complete and saved to `docs/superpowers/plans/2026-04-19-media-library-implementation-plan.md`. Two execution options:** |
||||
|
|
||||
|
**1. Subagent-Driven(推荐)** — 每个 Task 派生子代理、任务间复核,迭代快 |
||||
|
|
||||
|
**2. Inline Execution** — 本会话内按 Task 执行,使用 executing-plans、批量推进并设检查点 |
||||
|
|
||||
|
**你更倾向哪一种?** 若不需要子代理流程,直接回复 **「在本会话实现」** 或 **「按 Task 顺序开始写代码」** 即可。 |
||||
Loading…
Reference in new issue