feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

@@ -0,0 +1,330 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import {
createApiClient,
resolvePublicApiBaseUrl,
type PublicManagedSubscription,
} from '../../lib/api/client';
const token = Astro.url.searchParams.get('token')?.trim() ?? '';
const api = createApiClient({ requestUrl: Astro.url });
const apiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
const EVENT_OPTIONS = [
{ value: 'post.published', label: '新文章通知' },
{ value: 'digest.weekly', label: '周报 Digest' },
{ value: 'digest.monthly', label: '月报 Digest' },
{ value: 'comment.created', label: '评论通知' },
{ value: 'friend_link.created', label: '友链申请通知' },
] as const;
const asObject = (value: unknown) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {} as Record<string, unknown>;
}
return value as Record<string, unknown>;
};
const normalizeStringList = (value: unknown) => {
if (!Array.isArray(value)) {
return [] as string[];
}
return value
.map((item) => String(item).trim())
.filter(Boolean);
};
let subscription: PublicManagedSubscription | null = null;
let errorMessage = '';
if (token) {
try {
const response = await api.getManagedSubscription(token);
subscription = response.subscription;
} catch (error) {
errorMessage = error instanceof Error ? error.message : '无法加载订阅信息。';
}
} else {
errorMessage = '缺少管理令牌。';
}
const filters = asObject(subscription?.filters);
const initialEvents = normalizeStringList(filters.event_types);
const initialCategories = normalizeStringList(filters.categories).join(', ');
const initialTags = normalizeStringList(filters.tags).join(', ');
const initialDisplayName = subscription?.display_name ?? '';
const initialStatus = subscription?.status === 'paused' ? 'paused' : 'active';
---
<BaseLayout title="管理订阅偏好" description="调整订阅偏好、暂停订阅或查看当前订阅状态。">
<section class="subscription-shell">
<div class="subscription-card" data-subscription-manage-root data-api-base={apiBaseUrl}>
<p class="subscription-kicker">subscriptions / manage</p>
<h1>管理订阅偏好</h1>
{subscription ? (
<>
<p class="subscription-copy">当前目标:<strong>{subscription.target}</strong> · 频道:{subscription.channel_type}</p>
<p class="subscription-copy">验证状态:{subscription.verified_at ? '已确认' : '待确认'} · 当前状态:{subscription.status}</p>
<form class="subscription-form" data-manage-form>
<input type="hidden" name="token" value={token} />
<label>
<span>称呼</span>
<input type="text" name="displayName" value={initialDisplayName} placeholder="例如:主邮箱 / 运营邮箱" />
</label>
<fieldset>
<legend>通知类型</legend>
<div class="subscription-grid">
{EVENT_OPTIONS.map((item) => (
<label class="subscription-checkbox">
<input type="checkbox" name="eventTypes" value={item.value} checked={initialEvents.includes(item.value)} />
<span>{item.label}</span>
</label>
))}
</div>
</fieldset>
<div class="subscription-split">
<label>
<span>只看这些分类(可选)</span>
<input type="text" name="categories" value={initialCategories} placeholder="Rust, Astro, 日志" />
</label>
<label>
<span>只看这些标签(可选)</span>
<input type="text" name="tags" value={initialTags} placeholder="web, ai, deploy" />
</label>
</div>
<fieldset>
<legend>订阅状态</legend>
<div class="subscription-grid compact">
<label class="subscription-checkbox">
<input type="radio" name="status" value="active" checked={initialStatus === 'active'} />
<span>继续接收</span>
</label>
<label class="subscription-checkbox">
<input type="radio" name="status" value="paused" checked={initialStatus === 'paused'} />
<span>先暂停</span>
</label>
</div>
</fieldset>
<div class="subscription-actions">
<button type="submit">保存偏好</button>
<a href={`/subscriptions/unsubscribe?token=${encodeURIComponent(token)}`}>去退订</a>
</div>
</form>
<p class="subscription-status" data-manage-status>你可以随时在这里调整通知类型、暂停或退订。</p>
</>
) : (
<p class="subscription-status is-warning">{errorMessage}</p>
)}
</div>
</section>
</BaseLayout>
{subscription && (
<script>
const root = document.querySelector('[data-subscription-manage-root]');
const form = root?.querySelector?.('[data-manage-form]');
const status = root?.querySelector?.('[data-manage-status]');
const apiBase = root?.getAttribute?.('data-api-base') || '';
if (form instanceof HTMLFormElement && status instanceof HTMLElement && apiBase) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
const token = String(formData.get('token') || '').trim();
const displayName = String(formData.get('displayName') || '').trim();
const statusValue = String(formData.get('status') || 'active').trim();
const categories = String(formData.get('categories') || '')
.split(/[,]/)
.map((item) => item.trim())
.filter(Boolean);
const tags = String(formData.get('tags') || '')
.split(/[,]/)
.map((item) => item.trim())
.filter(Boolean);
const eventTypes = formData
.getAll('eventTypes')
.map((item) => String(item).trim())
.filter(Boolean);
status.textContent = '保存中...';
const filters = {
event_types: eventTypes,
categories,
tags,
};
try {
const response = await fetch(`${apiBase}/subscriptions/manage`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
displayName: displayName || null,
status: statusValue,
filters,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.description || payload?.message || '保存失败,请稍后重试。');
}
status.textContent = '偏好已保存。';
} catch (error) {
status.textContent = error instanceof Error ? error.message : '保存失败,请稍后重试。';
}
});
}
</script>
)}
<style>
.subscription-shell {
max-width: 54rem;
margin: 0 auto;
padding: 2rem 1rem 4rem;
}
.subscription-card {
border: 1px solid rgba(94, 234, 212, 0.16);
background: linear-gradient(135deg, rgba(15, 23, 42, 0.86), rgba(15, 23, 42, 0.72));
border-radius: 1.25rem;
padding: 1.5rem;
color: var(--text-primary);
}
.subscription-kicker {
margin: 0 0 0.5rem;
color: var(--primary);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.22em;
}
h1 {
margin: 0;
font-size: 1.75rem;
}
.subscription-copy {
margin: 0.65rem 0 0;
color: var(--text-secondary);
line-height: 1.75;
}
.subscription-form {
display: grid;
gap: 1rem;
margin-top: 1.4rem;
}
.subscription-form label,
.subscription-form fieldset {
display: grid;
gap: 0.5rem;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 1rem;
padding: 1rem;
background: rgba(15, 23, 42, 0.24);
}
.subscription-form span,
.subscription-form legend {
color: var(--text-secondary);
font-size: 0.9rem;
}
.subscription-form input[type='text'] {
width: 100%;
border-radius: 0.8rem;
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.45);
color: var(--text-primary);
padding: 0.85rem 0.95rem;
}
.subscription-grid {
display: grid;
gap: 0.75rem;
}
.subscription-grid.compact {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.subscription-checkbox {
display: flex !important;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 0.95rem !important;
}
.subscription-checkbox input {
accent-color: var(--primary);
}
.subscription-split {
display: grid;
gap: 1rem;
}
.subscription-actions {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
margin-top: 0.25rem;
}
.subscription-actions button,
.subscription-actions a {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 0;
padding: 0.9rem 1.05rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.subscription-actions button {
color: #08111f;
background: linear-gradient(135deg, var(--primary), #8b5cf6);
}
.subscription-actions a {
color: var(--text-primary);
border: 1px solid rgba(148, 163, 184, 0.24);
}
.subscription-status {
margin: 1rem 0 0;
color: var(--text-secondary);
line-height: 1.8;
}
.subscription-status.is-warning {
padding: 0.95rem 1rem;
border-radius: 0.95rem;
background: rgba(245, 158, 11, 0.14);
color: #fde68a;
}
@media (min-width: 900px) {
.subscription-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.subscription-split {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>