Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/admin/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface ApiSuccessResponse<T, M = Record<string, unknown>> {

const adminMessageTranslations = new Map<string, string>([
['one or more fields failed validation', '有字段校验未通过,请检查标红项。'],
['only archived articles can be deleted', '只有已归档的文章才允许删除。'],
['response payload is missing the data field', '响应数据缺少 data 字段。'],
['article not found', '未找到文章。'],
['asset not found', '未找到媒体文件。'],
Expand Down
1 change: 1 addition & 0 deletions apps/admin/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ export const formatAuditSummary = (summary: string) => {
[/^Updated article (.+)$/i, (m) => `已更新文章 ${m[1]}`],
[/^Published article (.+)$/i, (m) => `已发布文章 ${m[1]}`],
[/^Archived article (.+)$/i, (m) => `已归档文章 ${m[1]}`],
[/^Deleted article (.+)$/i, (m) => `已删除文章 ${m[1]}`],
[/^Created GeekDaily episode (.+)$/i, (m) => `已创建极客日报第 ${m[1]} 期`],
[/^Updated GeekDaily episode (.+)$/i, (m) => `已更新极客日报第 ${m[1]} 期`],
[/^Created GeekDaily WeChat draft (.+)$/i, (m) => `已创建极客日报第 ${m[1]} 期微信公众号草稿`],
Expand Down
35 changes: 32 additions & 3 deletions apps/admin/src/views/ArticleEditorPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const article = ref<AdminArticleRecord | null>(null);
const loading = ref(true);
const saving = ref(false);
const actioning = ref(false);
const deleting = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const fieldIssues = ref<Record<string, string>>({});
Expand All @@ -66,6 +67,7 @@ const saveButtonLabel = computed(() => ((isNew.value || form.status === 'draft')
const saveButtonClass = computed(() => ['button-link', !canPublish.value && 'button-primary'].filter(Boolean).join(' '));
const canPublish = computed(() => form.status !== 'published');
const canArchive = computed(() => Boolean(article.value) && form.status !== 'archived');
const canDelete = computed(() => Boolean(article.value) && form.status === 'archived');
const hasIssue = (...paths: string[]) =>
paths.some((path) => {
if (fieldIssues.value[path]) {
Expand Down Expand Up @@ -212,6 +214,30 @@ const runAction = async (action: 'archive') => {
}
};

const deleteArticle = async () => {
if (!article.value) {
return;
}

const confirmed = window.confirm(`确定删除文章“${article.value.title}”吗?此操作不可恢复。`);
if (!confirmed) {
return;
}

resetFeedback();
deleting.value = true;
try {
await adminRequest<{ id: string; title: string }>(`/api/admin/v1/articles/${article.value.id}`, {
method: 'DELETE',
});
await router.push('/articles');
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '无法删除文章。';
} finally {
deleting.value = false;
}
};

watch(() => route.fullPath, () => void loadArticle());
onMounted(() => void loadArticle());
</script>
Expand All @@ -232,13 +258,16 @@ onMounted(() => void loadArticle());

<div class="page-actions article-page-header-actions">
<RouterLink class="button-link" to="/articles">返回列表</RouterLink>
<button :class="saveButtonClass" type="button" :disabled="loading || saving || actioning" @click="save">
<button :class="saveButtonClass" type="button" :disabled="loading || saving || actioning || deleting" @click="save">
{{ saving ? '保存中…' : saveButtonLabel }}
</button>
<button v-if="canPublish" class="button-link button-primary" type="button" :disabled="loading || saving || actioning" @click="publish">
<button v-if="canPublish" class="button-link button-primary" type="button" :disabled="loading || saving || actioning || deleting" @click="publish">
{{ actioning ? '发布中…' : '发布' }}
</button>
<button v-if="canArchive" class="button-link button-danger" type="button" :disabled="saving || actioning" @click="runAction('archive')">归档</button>
<button v-if="canArchive" class="button-link button-danger" type="button" :disabled="saving || actioning || deleting" @click="runAction('archive')">归档</button>
<button v-if="canDelete" class="button-link button-danger" type="button" :disabled="saving || actioning || deleting" @click="deleteArticle">
{{ deleting ? '删除中…' : '删除文章' }}
</button>
</div>
</header>

Expand Down
43 changes: 42 additions & 1 deletion apps/admin/src/views/ArticlesPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '@rebase/shared';

import PaginationBar from '../components/PaginationBar.vue';
import { adminFetchWithMeta } from '../lib/api';
import { adminFetchWithMeta, adminRequest } from '../lib/api';
import { formatContentStatus, formatDateTime } from '../lib/format';
import { getPublicSiteUrl } from '../lib/runtime-config';

Expand All @@ -22,7 +22,9 @@ const rows = ref<AdminArticleListItem[]>([]);
const pagination = ref<PaginatedMeta | null>(null);
const loading = ref(true);
const errorMessage = ref('');
const successMessage = ref('');
const page = ref(1);
const deletingArticleId = ref('');
const statusFilterOpen = ref(false);
const statusFilterRef = ref<HTMLElement | null>(null);

Expand Down Expand Up @@ -78,6 +80,35 @@ const loadRows = async () => {
}
};

const deleteArticle = async (row: AdminArticleListItem) => {
const confirmed = window.confirm(`确定删除已归档文章“${row.title}”吗?此操作不可恢复。`);
if (!confirmed) {
return;
}

deletingArticleId.value = row.id;
errorMessage.value = '';
successMessage.value = '';

try {
await adminRequest<{ id: string; title: string }>(`/api/admin/v1/articles/${row.id}`, {
method: 'DELETE',
});
successMessage.value = `已删除文章:${row.title}`;

if (rows.value.length === 1 && page.value > 1) {
page.value -= 1;
return;
}

await loadRows();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '无法删除文章。';
} finally {
deletingArticleId.value = '';
}
};

const goToPage = (nextPage: number) => {
page.value = nextPage;
};
Expand Down Expand Up @@ -157,6 +188,7 @@ onBeforeUnmount(() => {
<div v-else-if="loading" class="panel"><p>正在加载文章列表…</p></div>

<template v-else>
<div v-if="successMessage" class="panel panel-success"><p>{{ successMessage }}</p></div>
<section class="panel admin-list-toolbar-panel">
<div class="admin-list-toolbar-row">
<div class="admin-list-toolbar-summary">
Expand Down Expand Up @@ -242,6 +274,15 @@ onBeforeUnmount(() => {
<div class="table-action-list admin-list-actions">
<RouterLink class="table-link" :to="`/articles/${row.id}/edit`">编辑</RouterLink>
<a class="table-link" :href="getArticlePreviewUrl(row.publicNumber, row.slug)" target="_blank" rel="noreferrer">前台预览</a>
<button
v-if="row.status === 'archived'"
class="table-link table-link-button"
type="button"
:disabled="deletingArticleId === row.id"
@click="deleteArticle(row)"
>
{{ deletingArticleId === row.id ? '删除中…' : '删除' }}
</button>
</div>
</td>
</tr>
Expand Down
29 changes: 28 additions & 1 deletion apps/api/src/lib/articles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { AdminArticleListItem, ArticleInput, ContentStatus, PaginatedResult
import { createAuditEntry, type AuditActor } from './audit.js';
import { listPublicAssetUrlsById } from './assets.js';
import { getDb } from './db.js';
import { notFound } from './errors.js';
import { badRequest, notFound } from './errors.js';
import { buildPaginatedMeta, resolvePagination, type PaginationInput } from './pagination.js';
import { combineFilters, toContainsPattern } from './query-filters.js';
import { ensurePublishedAt, parsePublicNumber, toIsoString } from './utils.js';
Expand Down Expand Up @@ -218,6 +218,33 @@ export const archiveAdminArticle = async (id: string, actor: AuditActor) => {
return mapArticleDetail(updated);
};

export const deleteAdminArticle = async (id: string, actor: AuditActor) => {
const db = getDb();
const current = await getAdminArticle(id);
if (!current) {
throw notFound('article not found');
}

if (current.status !== 'archived') {
throw badRequest('only archived articles can be deleted');
}

await db.delete(articles).where(eq(articles.id, id));

await createAuditEntry({
...actor,
action: 'article.delete',
targetType: 'article',
targetId: current.id,
summary: `Deleted article ${current.title}`,
});

return {
id: current.id,
title: current.title,
};
};

export const listPublicArticles = async () => {
const db = getDb();
const rows = await db
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
import { listAuditEntries } from '../lib/audit.js';
import { getDashboardStats } from '../lib/access.js';
import { maxAssetUploadBodyBytes } from '../lib/asset-storage.js';
import { createAdminArticle, getAdminArticle, listAdminArticles, publishAdminArticle, updateAdminArticle, archiveAdminArticle } from '../lib/articles.js';
import { archiveAdminArticle, createAdminArticle, deleteAdminArticle, getAdminArticle, listAdminArticles, publishAdminArticle, updateAdminArticle } from '../lib/articles.js';
import { createAdminAsset, deleteAdminAsset, getAdminAsset, getAdminAssetUploadConfig, listAdminAssets, updateAdminAsset, uploadAdminAsset } from '../lib/assets.js';
import { createAdminContributor, createAdminContributorRole, getAdminContributor, listAdminContributorRoles, listAdminContributors, updateAdminContributor, updateAdminContributorRole } from '../lib/contributors.js';
import { createAdminEvent, getAdminEvent, listAdminEvents, publishAdminEvent, updateAdminEvent, archiveAdminEvent } from '../lib/events.js';
Expand Down Expand Up @@ -109,6 +109,7 @@ adminRoutes.patch('/articles/:id', requireActiveStaff('article.write'), async (c
});
adminRoutes.post('/articles/:id/publish', requireActiveStaff('article.publish'), async (c) => c.json(ok(await publishAdminArticle(c.req.param('id'), getAuditActor(c)))));
adminRoutes.post('/articles/:id/archive', requireActiveStaff('article.publish'), async (c) => c.json(ok(await archiveAdminArticle(c.req.param('id'), getAuditActor(c)))));
adminRoutes.delete('/articles/:id', requireActiveStaff('article.publish'), async (c) => c.json(ok(await deleteAdminArticle(c.req.param('id'), getAuditActor(c)))));

adminRoutes.get('/jobs', requireActiveStaff('job.read'), async (c) => {
const result = await listAdminJobs({
Expand Down
8 changes: 8 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ const { defineConfig, devices } = require('@playwright/test');

const smokeApiPort = Number.parseInt(process.env.SMOKE_API_PORT || '8789', 10);
const smokeWebPort = Number.parseInt(process.env.SMOKE_WEB_PORT || '4324', 10);
const smokeAdminPort = Number.parseInt(process.env.SMOKE_ADMIN_PORT || '5175', 10);
const smokeApiBaseUrl = `http://127.0.0.1:${smokeApiPort}`;
const smokeWebBaseUrl = `http://127.0.0.1:${smokeWebPort}`;
const smokeAdminBaseUrl = `http://127.0.0.1:${smokeAdminPort}`;

module.exports = defineConfig({
testDir: './tests/smoke',
Expand Down Expand Up @@ -45,5 +47,11 @@ module.exports = defineConfig({
reuseExistingServer: !process.env.CI,
timeout: 240000,
},
{
command: `VITE_API_BASE_URL=${smokeApiBaseUrl} VITE_PUBLIC_SITE_BASE_URL=${smokeWebBaseUrl} pnpm --filter @rebase/admin exec vite --host 127.0.0.1 --port ${smokeAdminPort} --strictPort`,
url: `${smokeAdminBaseUrl}/login`,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
],
});
67 changes: 67 additions & 0 deletions scripts/local/check-stack.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { captureCommand } from './helpers.mjs';

const targets = [
{ label: 'api', url: 'http://127.0.0.1:8788' },
{ label: 'admin', url: 'http://127.0.0.1:5174' },
{ label: 'web', url: 'http://127.0.0.1:4321' },
];

const checkHttpTarget = async ({ label, url }) => {
try {
const response = await fetch(url, { redirect: 'manual' });
return {
label,
ok: response.ok,
status: response.status,
detail: url,
};
} catch (error) {
return {
label,
ok: false,
status: null,
detail: error instanceof Error ? error.message : String(error),
};
}
};

const checkPostgresContainer = async () => {
try {
const { stdout } = await captureCommand('docker', [
'inspect',
'-f',
'{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}',
'rebase-postgres',
]);
const status = stdout.trim();

return {
label: 'postgres',
ok: status === 'healthy' || status === 'running',
status,
detail: 'rebase-postgres',
};
} catch (error) {
return {
label: 'postgres',
ok: false,
status: null,
detail: error instanceof Error ? error.message : String(error),
};
}
};

const results = await Promise.all([
...targets.map((target) => checkHttpTarget(target)),
checkPostgresContainer(),
]);

for (const result of results) {
const status = result.status ?? 'unreachable';
const mark = result.ok ? 'OK' : 'FAIL';
console.log(`${mark} ${result.label.padEnd(8)} ${status} ${result.detail}`);
}

if (results.some((result) => !result.ok)) {
process.exitCode = 1;
}
60 changes: 60 additions & 0 deletions scripts/local/test-stack.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { runCommand } from './helpers.mjs';

const cwd = new URL('../../', import.meta.url);
const command = process.argv[2] ?? 'help';
const extraArgs = process.argv.slice(3);

const runNodeScript = (scriptPath, args = []) =>
runCommand(process.execPath, [scriptPath, ...args], { cwd });

const runPnpm = (args = []) =>
runCommand('corepack', ['pnpm', ...args], { cwd });

const tasks = {
help: async () => {
console.log(`Usage:
node scripts/local/test-stack.mjs bootstrap
node scripts/local/test-stack.mjs content
node scripts/local/test-stack.mjs ready
node scripts/local/test-stack.mjs start [--services=api,admin,web]
node scripts/local/test-stack.mjs status
node scripts/local/test-stack.mjs smoke

Commands:
bootstrap Initialize postgres, migrations, seed data, and local admin account
content Reset imported WeChat drafts and re-import content items
ready Run bootstrap, then content
start Start local api/admin/web services
status Check postgres, api, admin, and web availability
smoke Run smoke tests
`);
},
bootstrap: async () => {
await runNodeScript('scripts/local/bootstrap-stack.mjs');
},
content: async () => {
await runPnpm(['--filter', '@rebase/api', 'reset-wechat-import-drafts', '--write']);
await runPnpm(['--filter', '@rebase/api', 'import-content-items', '--write']);
},
ready: async () => {
await tasks.bootstrap();
await tasks.content();
},
start: async () => {
await runNodeScript('scripts/local/dev-stack.mjs', extraArgs);
},
status: async () => {
await runNodeScript('scripts/local/check-stack.mjs');
},
smoke: async () => {
await runPnpm(['test:smoke']);
},
};

if (!(command in tasks)) {
console.error(`Unknown command: ${command}`);
await tasks.help();
process.exit(1);
}

await tasks[command]();
Loading
Loading