feat: ship blog platform admin and deploy stack
This commit is contained in:
330
frontend/src/pages/subscriptions/manage.astro
Normal file
330
frontend/src/pages/subscriptions/manage.astro
Normal 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>
|
||||
Reference in New Issue
Block a user