@@ -981,39 +1023,10 @@ export default function AdminSettingsPage() {
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index bb7eef3..0b64ddb 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -2293,6 +2293,253 @@ body:has(.admin-topbar) .ptr-indicator {
color: var(--j13-green);
}
+.post-row--compact {
+ padding: 10px 20px;
+ gap: 10px;
+}
+
+.post-row--compact .post-body {
+ gap: 4px;
+}
+
+.post-row--compact .post-avatar {
+ width: 32px;
+ height: 32px;
+ font-size: 13px;
+ margin-top: 0;
+}
+
+.post-row--has-thumb {
+ align-items: center;
+}
+
+/* v2 紧凑列表布局 */
+.post-row--v2 {
+ padding: 8px 16px;
+ gap: 8px;
+ align-items: flex-start;
+}
+
+.post-row--v2 .post-avatar {
+ width: 36px;
+ height: 36px;
+ font-size: 14px;
+ margin-top: 1px;
+}
+
+.post-row--v2.post-row--has-thumb {
+ align-items: flex-start;
+}
+
+.post-row--v2 .post-main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ overflow: hidden;
+}
+
+.post-row--v2 .post-main--with-thumb {
+ flex-direction: row;
+ align-items: stretch;
+ gap: 10px;
+ min-width: 0;
+}
+
+.post-row--v2 .post-main--with-thumb .post-content {
+ flex: 1 1 0%;
+ min-width: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.post-row--v2 .post-main--with-thumb .post-aside {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-end;
+ gap: 10px;
+ flex: 0 0 auto;
+ flex-shrink: 0;
+ min-width: max-content;
+}
+
+.post-row--v2 .post-main--with-thumb .post-thumb {
+ width: 64px;
+ flex: 0 0 64px;
+ flex-shrink: 0;
+ align-self: stretch;
+ border-radius: 4px;
+ overflow: hidden;
+ background: var(--j13-bg-block-accent, #f1f5f9);
+}
+
+.post-row--v2 .post-main--with-thumb .post-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.post-row--v2 .post-main--with-thumb .post-stats {
+ flex: 0 0 auto;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ min-width: max-content;
+ align-self: flex-end;
+}
+
+.post-row--v2 .post-main--with-thumb .post-stat {
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.post-row--v2 .post-main--with-thumb .post-stat svg {
+ flex-shrink: 0;
+ width: 12px;
+ height: 12px;
+}
+
+.post-row--v2 .post-main--with-thumb .post-meta--inline {
+ justify-content: flex-start;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.post-row--v2 .post-main--with-thumb .post-meta-left {
+ min-width: 0;
+ overflow: hidden;
+}
+
+.post-row--v2 .post-meta--inline {
+ justify-content: flex-start;
+}
+
+.post-row--v2 .post-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.post-row--v2 .post-title-row {
+ gap: 4px;
+}
+
+.post-row--v2 .post-title {
+ font-size: 14px;
+ line-height: 1.35;
+}
+
+.post-row--v2 .post-excerpt {
+ font-size: 12px;
+ line-height: 1.35;
+ color: var(--color-text-4);
+}
+
+.post-row--v2 .post-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-width: 0;
+ font-size: 12px;
+ color: var(--color-text-4);
+}
+
+.post-row--v2 .post-meta-left {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0;
+ min-width: 0;
+ flex: 1;
+}
+
+.post-row--v2 .post-meta-author {
+ color: var(--color-text-3);
+ font-weight: 400;
+ flex-shrink: 0;
+ max-width: 6rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.post-row--v2 .post-meta-author:hover {
+ color: var(--j13-green);
+}
+
+.post-row--v2 .post-meta-sep {
+ margin: 0 4px;
+ color: var(--color-text-4);
+ flex-shrink: 0;
+}
+
+.post-row--v2 .post-meta-time {
+ flex-shrink: 0;
+ font-variant-numeric: tabular-nums;
+}
+
+.post-row--v2 .post-meta-board {
+ color: var(--color-text-4);
+ flex-shrink: 0;
+ max-width: 5rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.post-row--v2 .post-list-tag {
+ padding: 0;
+ margin-left: 6px;
+ border-radius: 0;
+ background: transparent;
+ color: var(--color-text-4);
+ font-size: 12px;
+ max-width: 5rem;
+}
+
+.post-row--v2 .post-list-tag:hover {
+ background: transparent;
+ color: var(--j13-green);
+}
+
+.post-row--v2 .post-stats {
+ gap: 8px;
+}
+
+.post-row--v2 .post-stat {
+ font-size: 11px;
+ gap: 2px;
+}
+
+.post-row--v2 .post-stat svg {
+ width: 12px;
+ height: 12px;
+}
+
+.post-thumb {
+ flex-shrink: 0;
+ width: 72px;
+ height: 54px;
+ border-radius: 6px;
+ overflow: hidden;
+ background: var(--j13-bg-block-accent, #f1f5f9);
+}
+
+.post-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
.post-avatar {
width: 40px;
height: 40px;
@@ -2529,7 +2776,10 @@ body:has(.admin-topbar) .ptr-indicator {
.post-title-row .post-qa-badge,
.post-title-row .post-pin-badge,
.post-title-row .post-feature-badge,
-.post-title-row .post-status-badge {
+.post-title-row .post-status-badge,
+.post-title-row .post-bounty-badge,
+.post-title-row .post-type-badge--poll,
+.post-title-row .post-type-badge--lottery {
flex-shrink: 0;
}
@@ -2848,6 +3098,90 @@ a.post-title:visited {
margin-top: 2px;
}
+.skeleton--avatar-v2 {
+ width: 36px;
+ height: 36px;
+ margin-top: 1px;
+}
+
+.skeleton--title-v2 {
+ height: 14px;
+}
+
+.skeleton--excerpt-v2 {
+ height: 12px;
+}
+
+.skeleton--meta-line {
+ height: 12px;
+ flex: 1;
+ min-width: 0;
+ max-width: 55%;
+}
+
+.skeleton--thumb {
+ width: 64px;
+ height: 36px;
+ border-radius: 4px;
+ flex-shrink: 0;
+}
+
+.skeleton--thumb-tall {
+ height: auto;
+ align-self: stretch;
+ min-height: 52px;
+}
+
+.post-row--v2.post-row--skeleton .post-main--with-thumb {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ gap: 10px;
+ flex: 1;
+ min-width: 0;
+}
+
+.post-row--v2.post-row--skeleton .post-main--with-thumb .post-content {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.post-row--v2.post-row--skeleton .post-main--with-thumb .post-aside {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-end;
+ gap: 10px;
+ flex-shrink: 0;
+}
+
+.post-row--v2.post-row--skeleton .post-main--with-thumb .post-stats {
+ flex-direction: row;
+ flex-wrap: nowrap;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 8px;
+ align-self: flex-end;
+}
+
+.post-row--v2.post-row--skeleton .post-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.post-row--v2.post-row--skeleton .post-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+}
+
.skeleton--title {
height: 18px;
max-width: 100%;
@@ -6019,6 +6353,256 @@ a.waline-comment-author:hover {
.widget-card-icon--hot { color: #e74c3c; }
.widget-card-icon--notice { color: #3498db; }
+.widget-card-icon--links { color: #2d6a4f; }
+
+.widget-card-head--split {
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.widget-card-head-main {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.widget-friend-links-apply {
+ height: 24px;
+ padding: 0 8px;
+ font-size: 12px;
+ flex-shrink: 0;
+}
+
+.widget-friend-links-title {
+ border: none;
+ background: none;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+}
+.widget-friend-links-title:hover {
+ color: var(--j13-green);
+}
+
+.widget-friend-links-more {
+ margin-top: 6px;
+ border: none;
+ background: none;
+ padding: 0;
+ font-size: 12px;
+ color: var(--j13-green);
+ cursor: pointer;
+}
+.widget-friend-links-more:hover {
+ text-decoration: underline;
+}
+
+.widget-friend-links-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 6px;
+}
+
+.widget-friend-links-list a {
+ color: var(--j13-link, var(--primary, #2d6a4f));
+ text-decoration: none;
+ font-size: 13px;
+}
+
+.widget-friend-links-list a:hover {
+ text-decoration: underline;
+}
+
+.friend-link-site-info {
+ padding: 14px 16px;
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--j13-green) 6%, #eef6ff 94%);
+ border: 1px solid color-mix(in srgb, var(--j13-green) 18%, #dbeafe 82%);
+}
+.friend-link-site-info__title {
+ margin: 0 0 10px;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--foreground, #1f2937);
+}
+.friend-link-site-info__list {
+ margin: 0;
+ display: grid;
+ gap: 6px;
+}
+.friend-link-site-info__item {
+ display: grid;
+ grid-template-columns: 42px 1fr;
+ gap: 8px;
+ align-items: baseline;
+ font-size: 13px;
+ line-height: 1.5;
+}
+.friend-link-site-info__item dt {
+ margin: 0;
+ color: var(--muted-fg, #64748b);
+ font-weight: 500;
+}
+.friend-link-site-info__item dd {
+ margin: 0;
+ min-width: 0;
+ word-break: break-all;
+ color: var(--foreground, #334155);
+}
+.friend-link-site-info__item dd a {
+ color: var(--j13-green);
+ text-decoration: none;
+}
+.friend-link-site-info__item dd a:hover {
+ text-decoration: underline;
+}
+
+.friend-link-apply-dialog {
+ gap: 16px;
+ padding: 0;
+ overflow: hidden;
+}
+.friend-link-apply-dialog > button.absolute {
+ top: 14px;
+ right: 14px;
+}
+.friend-link-apply-dialog__header {
+ position: relative;
+ padding: 16px 20px 0;
+ margin: 0;
+ text-align: left;
+}
+.friend-link-apply-dialog__header h2 {
+ font-size: 17px;
+ font-weight: 600;
+}
+
+.friend-link-apply-dialog .friend-link-site-info {
+ margin: 0 20px;
+}
+.friend-link-apply-form {
+ display: grid;
+ gap: 14px;
+ padding: 0 20px;
+}
+.friend-link-apply-field {
+ display: grid;
+ gap: 6px;
+}
+.friend-link-apply-field label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--foreground, #334155);
+}
+.friend-link-apply-field input {
+ height: 38px;
+ border-radius: 8px;
+ font-size: 13px;
+}
+.friend-link-apply-field__hint {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.45;
+ color: var(--muted-fg, #64748b);
+}
+.friend-link-apply-placement {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.friend-link-apply-placement__option {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--j13-border-light, #e5e7eb);
+ border-radius: 8px;
+ background: var(--j13-bg-surface, #fff);
+ font-size: 13px;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+}
+.friend-link-apply-placement__option:hover {
+ border-color: color-mix(in srgb, var(--j13-green) 30%, var(--j13-border-light, #e5e7eb));
+}
+.friend-link-apply-placement__option--active {
+ border-color: var(--j13-green, #2d6a4f);
+ background: var(--j13-bg-block-accent, #f0faf5);
+ color: var(--j13-green, #2d6a4f);
+ font-weight: 600;
+}
+
+.friend-link-apply-logo-row {
+ display: flex;
+ gap: 8px;
+ align-items: stretch;
+}
+.friend-link-apply-logo-preview {
+ flex-shrink: 0;
+ width: 72px;
+ height: 38px;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px dashed var(--border, #cbd5e1);
+ background: var(--background, #fff);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--muted-fg, #94a3b8);
+ font-size: 12px;
+}
+.friend-link-apply-logo-preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+.friend-link-apply-logo-input {
+ flex: 1;
+ min-width: 0;
+}
+.friend-link-apply-logo-upload {
+ flex-shrink: 0;
+ height: 38px;
+ gap: 4px;
+ padding-left: 12px;
+ padding-right: 12px;
+}
+
+.friend-link-apply-dialog__footer {
+ flex-direction: column;
+ gap: 12px;
+ padding: 4px 20px 20px;
+ margin: 0;
+}
+@media (min-width: 640px) {
+ .friend-link-apply-dialog__footer {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+}
+.friend-link-apply-dialog__note {
+ margin: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--muted-fg, #64748b);
+}
+.friend-link-apply-dialog__actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ width: 100%;
+}
+@media (min-width: 640px) {
+ .friend-link-apply-dialog__actions {
+ width: auto;
+ }
+}
.widget-card-body { padding: 6px 14px 12px; }
@@ -6031,12 +6615,189 @@ a.waline-comment-author:hover {
.widget-card--about .widget-card-body {
display: flex;
- align-items: center;
- justify-content: center;
+ flex-direction: column;
+ align-items: stretch;
text-align: center;
padding: 20px 16px;
}
+.widget-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin-top: 14px;
+ padding-top: 14px;
+ border-top: 1px solid var(--j13-border-light);
+}
+
+.widget-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+}
+
+.widget-stat-value {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--j13-green);
+ line-height: 1.2;
+}
+
+.widget-stat-label {
+ font-size: 11px;
+ color: var(--color-text-4);
+ line-height: 1.3;
+}
+
+.widget-checkin {
+ margin-top: 14px;
+ padding-top: 14px;
+ border-top: 1px solid var(--j13-border-light);
+}
+
+.widget-checkin-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--j13-border-light);
+ background: var(--j13-bg-block-muted);
+}
+
+.widget-checkin-panel--guest {
+ background: var(--j13-bg-block-muted);
+ border-color: var(--j13-border-light);
+}
+
+.widget-checkin-main {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.widget-checkin-icon {
+ flex-shrink: 0;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ color: var(--color-text-3);
+ background: var(--j13-bg-block);
+ border: 1px solid var(--j13-border-light);
+}
+
+.widget-checkin-panel--done .widget-checkin-icon {
+ color: var(--j13-green);
+ background: var(--j13-bg-block);
+ border-color: var(--j13-border-light);
+ box-shadow: none;
+}
+
+.widget-checkin-panel--guest .widget-checkin-icon {
+ color: var(--color-text-3);
+ background: var(--j13-bg-block);
+ border-color: var(--j13-border-light);
+}
+
+.widget-checkin-info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ text-align: left;
+}
+
+.widget-checkin-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--color-text-2);
+ line-height: 1.35;
+}
+
+.widget-checkin-meta {
+ font-size: 11px;
+ color: var(--color-text-4);
+ line-height: 1.45;
+}
+
+.widget-checkin-reward {
+ flex-shrink: 0;
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1;
+ color: var(--color-text-3);
+ font-variant-numeric: tabular-nums;
+}
+
+.widget-checkin-reward::before {
+ content: '+';
+ color: var(--j13-green);
+ opacity: 0.85;
+}
+
+.widget-checkin-action {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ width: 100%;
+ height: 32px;
+ margin: 0;
+ padding: 0 12px;
+ border: 1px solid var(--j13-border);
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--color-text-2);
+ background: var(--j13-bg-block);
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
+}
+
+.widget-checkin-action:hover:not(:disabled) {
+ color: var(--j13-green);
+ border-color: color-mix(in srgb, var(--j13-green) 35%, var(--j13-border));
+ background: var(--j13-green-bg);
+}
+
+.widget-checkin-action:active:not(:disabled) {
+ background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-block));
+}
+
+.widget-checkin-action:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+}
+
+.widget-checkin-panel--guest .widget-checkin-action {
+ color: var(--color-text-2);
+ background: var(--j13-bg-block);
+ border-color: var(--j13-border);
+}
+
+.widget-checkin-panel--guest .widget-checkin-action:hover {
+ color: var(--j13-green);
+ background: var(--j13-green-bg);
+ border-color: color-mix(in srgb, var(--j13-green) 35%, var(--j13-border));
+}
+
+.widget-checkin-action-spinner {
+ color: var(--j13-green);
+}
+
+.widget-checkin-skeleton {
+ height: 76px;
+ width: 100%;
+ border-radius: 10px;
+}
+
.widget-about-text {
margin: 0;
font-size: 12px;
@@ -6634,6 +7395,10 @@ a.user-link--avatar-only:focus-visible {
}
.widget-item--comment .widget-item-title {
+ flex: none;
+ display: block;
+ max-width: 100%;
+ min-width: 0;
color: var(--color-text-2);
transition: color 0.12s;
}
@@ -6642,8 +7407,9 @@ a.user-link--avatar-only:focus-visible {
flex: 1;
min-width: 0;
display: flex;
- align-items: center;
- gap: 8px;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.2rem;
margin: 0;
padding: 0;
border: none;
@@ -6654,6 +7420,31 @@ a.user-link--avatar-only:focus-visible {
cursor: pointer;
}
+.widget-item--comment .widget-item-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ font-size: 0.72rem;
+ color: var(--color-text-4, #94a3b8);
+ min-width: 0;
+}
+
+.widget-item-post-title {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-align: right;
+}
+
+.skeleton--widget-meta {
+ width: 55%;
+ height: 10px;
+ flex-shrink: 0;
+}
+
.widget-item-comment-main:hover .widget-item-title,
.widget-item-comment-main:focus-visible .widget-item-title {
color: var(--j13-green);
@@ -7505,6 +8296,15 @@ button.profile-stat:hover strong {
font-weight: 600;
}
+.compose-type-pill:disabled {
+ cursor: not-allowed;
+ opacity: 0.72;
+}
+
+.compose-type-pill.active:disabled {
+ opacity: 1;
+}
+
/* 板块:带图标的选择芯片 */
.compose-board-pill {
display: inline-flex;
@@ -11707,3 +12507,1039 @@ a.pm-thread-head__name:hover {
font-weight: 600;
}
+/* 自定义单页 / 友情链接 / 特殊帖 */
+.site-page__head h1 { margin: 0 0 1rem; font-size: 1.5rem; }
+.links-page__head h1 { margin: 0 0 0.5rem; }
+.links-page__hint { color: var(--muted-fg, #666); margin: 0 0 1rem; }
+.links-page__list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; }
+.links-page__list a { color: var(--primary, #2d6a4f); text-decoration: none; }
+.compose-special { margin-top: 1rem; padding: 1rem; border: 1px solid var(--j13-border); border-radius: 8px; display: grid; gap: 0.75rem; }
+.compose-special__head { display: flex; flex-direction: column; gap: 0.5rem; }
+.compose-special__poll-mode {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem 0.75rem;
+ padding: 0.5rem 0.65rem;
+ border-radius: 6px;
+ background: var(--j13-bg-block-muted, #f9fafb);
+ border: 1px solid var(--j13-border);
+}
+.compose-special__poll-mode label { font-size: 0.875rem; cursor: pointer; user-select: none; }
+.compose-special__poll-deadline {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ padding: 0.5rem 0.65rem;
+ border-radius: 6px;
+ background: var(--j13-bg-block-muted, #f9fafb);
+ border: 1px solid var(--j13-border);
+}
+.compose-special__poll-deadline-toggle,
+.compose-special__poll-deadline-field {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem 0.75rem;
+ flex-wrap: wrap;
+ width: fit-content;
+ max-width: 100%;
+}
+.compose-special__poll-deadline label { font-size: 0.875rem; cursor: pointer; user-select: none; }
+.compose-special__poll-deadline-label { margin: 0; font-size: 0.875rem; font-weight: 500; white-space: nowrap; }
+.compose-special__poll-deadline-input {
+ width: auto;
+ min-width: 11.5rem;
+ max-width: 100%;
+ flex: 0 0 auto;
+}
+.compose-special__max-choices-label,
+.compose-special__max-choices-suffix { font-size: 0.8125rem; color: var(--color-text-3); }
+.compose-special__max-choices { width: 4.5rem; }
+.compose-special__options { display: grid; gap: 0.65rem; }
+.compose-special__option-row { display: flex; gap: 0.5rem; align-items: center; }
+.compose-special__hint { font-size: 0.85rem; color: var(--color-text-3); margin: 0; }
+.post-type-badge { display: inline-flex; align-items: center; margin-right: 0.35rem; padding: 0.1rem 0.45rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
+.post-type-badge--poll { background: #dbeafe; color: #1d4ed8; }
+.post-type-badge--bounty { background: #fef3c7; color: #b45309; }
+.post-type-badge--lottery { background: #fce7f3; color: #be185d; }
+.post-bounty-badge {
+ display: inline-flex;
+ align-items: center;
+ height: 20px;
+ padding: 0 7px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+ vertical-align: middle;
+}
+.post-bounty-badge--detail {
+ margin-right: 8px;
+ height: 22px;
+ padding: 0 8px;
+ font-size: 12px;
+}
+.post-bounty-badge--open {
+ color: #b45309;
+ background: rgba(245, 158, 11, 0.12);
+ border: 1px solid rgba(245, 158, 11, 0.22);
+}
+.post-bounty-badge--awarded {
+ color: var(--j13-green);
+ background: var(--j13-green-bg);
+ border: 1px solid color-mix(in srgb, var(--j13-green) 20%, transparent);
+}
+.dark .post-bounty-badge--open {
+ color: #fbbf24;
+ background: rgba(251, 191, 36, 0.14);
+ border-color: rgba(251, 191, 36, 0.22);
+}
+.post-poll, .post-lottery, .post-bounty {
+ margin: 1rem 0;
+ padding: 1rem;
+ border-radius: 8px;
+ border: 1px solid var(--j13-border);
+ background: var(--j13-bg-surface);
+}
+.post-bounty__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+.post-bounty__lead {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.65rem;
+ min-width: 0;
+}
+.post-bounty__icon { flex-shrink: 0; margin-top: 0.1rem; }
+.post-bounty__titles {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ min-width: 0;
+}
+.post-bounty__title { font-size: 0.9375rem; line-height: 1.35; }
+.post-bounty__points {
+ font-size: 1.125rem;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.2;
+}
+.post-bounty__subtitle {
+ font-size: 0.8125rem;
+ color: var(--color-text-3);
+ font-weight: 500;
+ line-height: 1.4;
+}
+.post-bounty__hint {
+ margin: 0.75rem 0 0;
+ font-size: 0.8125rem;
+ color: var(--color-text-3);
+ line-height: 1.5;
+}
+.post-bounty__actions {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 0.5rem;
+}
+.post-bounty__actions--inline { margin-top: 0.65rem; }
+.post-bounty__jump {
+ appearance: none;
+ border: none;
+ padding: 0;
+ background: none;
+ font: inherit;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--j13-green);
+ cursor: pointer;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.post-bounty__jump:hover { opacity: 0.85; }
+.post-bounty--open {
+ background: color-mix(in srgb, #f59e0b 10%, var(--j13-bg-surface));
+ border-color: color-mix(in srgb, #f59e0b 28%, var(--j13-border));
+}
+.post-bounty--open .post-bounty__icon,
+.post-bounty--open .post-bounty__title,
+.post-bounty--open .post-bounty__points { color: #b45309; }
+.post-bounty--awarded {
+ background: color-mix(in srgb, var(--j13-green) 8%, var(--j13-bg-surface));
+ border-color: color-mix(in srgb, var(--j13-green) 22%, var(--j13-border));
+}
+.post-bounty--awarded .post-bounty__icon,
+.post-bounty--awarded .post-bounty__title { color: var(--j13-green); }
+.post-bounty--refunded {
+ background: var(--j13-bg-block-muted);
+ border-color: var(--j13-border);
+}
+.post-bounty--refunded .post-bounty__icon,
+.post-bounty--refunded .post-bounty__title { color: var(--color-text-3); }
+.dark .post-bounty--open {
+ background: color-mix(in srgb, #f59e0b 12%, var(--j13-bg-block-muted));
+ border-color: color-mix(in srgb, #f59e0b 24%, var(--j13-border));
+}
+.dark .post-bounty--open .post-bounty__icon,
+.dark .post-bounty--open .post-bounty__title,
+.dark .post-bounty--open .post-bounty__points { color: #fbbf24; }
+.dark .post-bounty--awarded {
+ background: color-mix(in srgb, var(--j13-green) 10%, var(--j13-bg-block-muted));
+}
+.compose-special__bounty-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.65rem 1rem;
+}
+.compose-special__bounty-row input { width: min(100%, 10rem); }
+.compose-special__balance {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: var(--color-text-3);
+ line-height: 1.45;
+}
+.compose-special__balance--over {
+ color: hsl(var(--destructive));
+ font-weight: 600;
+}
+.bounty-award-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ padding: 0.2rem 0.55rem;
+ border-radius: 6px;
+ border: 1px solid color-mix(in srgb, #f59e0b 35%, var(--j13-border));
+ background: color-mix(in srgb, #f59e0b 10%, var(--j13-bg-surface));
+ color: #b45309;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+.bounty-award-btn:hover {
+ background: color-mix(in srgb, #f59e0b 16%, var(--j13-bg-surface));
+ border-color: color-mix(in srgb, #f59e0b 45%, var(--j13-border));
+}
+.dark .bounty-award-btn {
+ color: #fbbf24;
+ background: color-mix(in srgb, #f59e0b 14%, var(--j13-bg-block-muted));
+ border-color: color-mix(in srgb, #f59e0b 28%, var(--j13-border));
+}
+.waline-comment--bounty-awarded {
+ position: relative;
+ background: color-mix(in srgb, #f59e0b 6%, var(--j13-bg-surface));
+ box-shadow: inset 3px 0 0 #f59e0b;
+}
+.dark .waline-comment--bounty-awarded {
+ background: color-mix(in srgb, #f59e0b 10%, var(--j13-bg-block-muted));
+ box-shadow: inset 3px 0 0 #fbbf24;
+}
+.waline-comment-bounty-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.2rem;
+ margin-left: 0.35rem;
+ padding: 0.1rem 0.45rem;
+ border-radius: 4px;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ line-height: 1.2;
+ color: var(--j13-green);
+ background: var(--j13-green-bg);
+ border: 1px solid color-mix(in srgb, var(--j13-green) 20%, transparent);
+ vertical-align: middle;
+}
+.post-poll__head, .post-lottery__head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
+.post-lottery__winners { list-style: none; padding: 0; margin: 0.5rem 0 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
+.post-poll__meta { margin-left: auto; font-size: 0.8125rem; color: var(--color-text-3); font-weight: 500; }
+.post-poll__deadline {
+ font-size: 0.8125rem;
+ color: var(--color-text-3);
+ font-weight: 500;
+}
+.post-poll__options { list-style: none; padding: 0; margin: 0 0 0.75rem; display: grid; gap: 0.5rem; }
+button.post-poll__option,
+.post-poll__option {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ appearance: none;
+ font: inherit;
+ color: inherit;
+ margin: 0;
+ width: 100%;
+ text-align: left;
+ padding: 0.65rem 0.75rem;
+ border-radius: 8px;
+ border: 1px solid var(--j13-border);
+ background: color-mix(in srgb, var(--j13-green) 4%, var(--j13-bg-surface));
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
+}
+.post-poll__option:hover:not(:disabled) {
+ border-color: var(--j13-green);
+ background: color-mix(in srgb, var(--j13-green) 8%, var(--j13-bg-surface));
+}
+.post-poll__option.active {
+ border-color: var(--j13-green);
+ background: color-mix(in srgb, var(--j13-green) 12%, var(--j13-bg-surface));
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--j13-green) 20%, transparent);
+}
+.post-poll__option.mine { border-color: var(--j13-green); }
+.post-poll__option:disabled { cursor: default; opacity: 1; }
+.post-poll__option-main {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ width: 100%;
+ min-width: 0;
+}
+.post-poll__option-indicator {
+ flex-shrink: 0;
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid color-mix(in srgb, var(--j13-border) 70%, #9ca3af);
+ background: var(--j13-bg-surface);
+ transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
+}
+.post-poll__option--single .post-poll__option-indicator { border-radius: 999px; }
+.post-poll__option--multi .post-poll__option-indicator { border-radius: 3px; }
+.post-poll__option.active .post-poll__option-indicator {
+ border-color: var(--j13-green);
+ background: var(--j13-green);
+ box-shadow: inset 0 0 0 2px var(--j13-bg-surface);
+}
+.post-poll__option-text { flex: 1; min-width: 0; line-height: 1.4; }
+.post-poll__option-stat {
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ color: var(--color-text-3);
+ font-variant-numeric: tabular-nums;
+}
+.post-poll__option-bar {
+ display: block;
+ margin-top: 0.5rem;
+ position: relative;
+ height: 6px;
+ background: var(--j13-bg-block-muted);
+ border-radius: 999px;
+ overflow: hidden;
+}
+.post-poll__option-fill {
+ position: absolute;
+ inset: 0 auto 0 0;
+ background: var(--j13-green);
+ opacity: 0.45;
+ border-radius: 999px;
+ transition: width 0.25s ease;
+}
+.post-poll__actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem 0.75rem;
+}
+.post-poll__login-hint,
+.post-poll__select-hint {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: var(--color-text-3);
+}
+.dark .post-poll__option {
+ background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-block-muted));
+}
+.dark .post-poll__option:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--j13-green) 10%, var(--j13-bg-block-muted));
+}
+.dark .post-poll__option.active {
+ background: color-mix(in srgb, var(--j13-green) 14%, var(--j13-bg-block-muted));
+}
+.dark .post-poll__option-indicator { background: var(--j13-bg-block-muted); }
+.dark .post-poll__option.active .post-poll__option-indicator {
+ box-shadow: inset 0 0 0 2px var(--j13-bg-block-muted);
+}
+.dark .post-poll__option-bar { background: rgba(255, 255, 255, 0.08); }
+.admin-links-page-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+.admin-links-page-head__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+.admin-links-columns {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.admin-links-apply-card { margin-bottom: 0; }
+.admin-links-apply-card > .admin-card-head {
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.admin-links-apply-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.admin-links-reciprocal-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 500;
+ font-size: 13px;
+ color: hsl(var(--muted-foreground));
+}
+.admin-links-reciprocal-toggle .admin-settings-switch:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+.admin-links-published-card { margin-bottom: 0; }
+.admin-inline-link {
+ margin: 0 4px;
+ color: var(--j13-green);
+ text-decoration: none;
+}
+.admin-inline-link:hover { text-decoration: underline; }
+
+.admin-links-logo-thumb {
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid var(--border, #e5e7eb);
+ background: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.admin-links-logo-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+.admin-links-logo-thumb--placeholder {
+ font-weight: 700;
+ font-size: 14px;
+ color: var(--j13-green);
+ background: var(--j13-green-soft, #edfbf3);
+}
+
+.admin-links-row {
+ display: grid;
+ grid-template-columns: 72px 32px 140px minmax(120px, 1.4fr) minmax(120px, 1.2fr) 36px;
+ grid-template-areas: 'order preview name url logo actions';
+ gap: 8px;
+ align-items: center;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border, #e5e7eb);
+}
+.admin-links-row:last-of-type { border-bottom: none; }
+.admin-links-row.is-dragging {
+ opacity: 0.72;
+ background: var(--muted, #f8fafc);
+ border-radius: 6px;
+}
+
+.admin-links-table-head {
+ display: grid;
+ grid-template-columns: 72px 32px 140px minmax(120px, 1.4fr) minmax(120px, 1.2fr) 36px;
+ grid-template-areas: 'order preview name url logo actions';
+ gap: 8px;
+ align-items: center;
+ padding: 0 0 6px;
+ border-bottom: 1px solid var(--border, #e5e7eb);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--muted-foreground, #64748b);
+}
+.admin-links-table-body { gap: 0; }
+.admin-links-editor { padding-top: 12px; }
+
+.admin-sortable-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+.admin-sortable-list--boxed {
+ border: 1px solid var(--border, #e5e7eb);
+ border-radius: 10px;
+ overflow: hidden;
+ background: var(--card, #fff);
+}
+.admin-sortable-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 0.75rem;
+ align-items: center;
+ padding: 0.85rem 0.9rem;
+ border-bottom: 1px solid var(--border, #e5e7eb);
+ background: var(--card, #fff);
+}
+.admin-sortable-row:last-child { border-bottom: none; }
+.admin-sortable-row.is-dragging {
+ opacity: 0.72;
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
+ z-index: 1;
+}
+.admin-sortable-row__handle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--muted-foreground, #64748b);
+ cursor: grab;
+ touch-action: none;
+}
+.admin-sortable-row__handle:active { cursor: grabbing; }
+.admin-sortable-row__handle:hover {
+ background: var(--muted, #f1f5f9);
+ color: var(--foreground, #0f172a);
+}
+.admin-sortable-row__main {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+.admin-sortable-row__label {
+ font-weight: 600;
+ color: var(--foreground, #0f172a);
+}
+.admin-sortable-row__hint {
+ font-size: 12px;
+ line-height: 1.45;
+ color: var(--muted-foreground, #64748b);
+}
+.admin-sortable-row__order {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex-shrink: 0;
+}
+.admin-sortable-row--widget {
+ grid-template-columns: auto 1fr auto;
+}
+.admin-sortable-table-row.is-dragging {
+ opacity: 0.72;
+ background: var(--muted, #f8fafc);
+}
+.admin-sortable-grid-item {
+ position: relative;
+}
+.admin-sortable-grid-item.is-dragging {
+ opacity: 0.72;
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
+ z-index: 1;
+}
+.admin-sortable-grid-item__handle-wrap {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ z-index: 1;
+}
+
+.admin-links-row__order {
+ grid-area: order;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0;
+ flex-shrink: 0;
+}
+.admin-links-row__move {
+ flex-direction: row;
+}
+.admin-links-row__preview {
+ grid-area: preview;
+ flex-shrink: 0;
+ width: 28px;
+ height: 28px;
+ border-radius: 6px;
+ overflow: hidden;
+ border: 1px dashed var(--border, #cbd5e1);
+ background: var(--muted, #f8fafc);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ font-size: 11px;
+ color: var(--j13-green);
+}
+.admin-links-row__preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+.admin-links-field {
+ min-width: 0;
+}
+.admin-links-field--name {
+ grid-area: name;
+ max-width: 140px;
+}
+.admin-links-field--url { grid-area: url; }
+.admin-links-field--logo { grid-area: logo; }
+.admin-links-row__actions {
+ grid-area: actions;
+ display: flex;
+ justify-content: center;
+}
+.admin-links-editor__foot {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px dashed var(--border, #e5e7eb);
+}
+
+.admin-links-pending-list { display: grid; gap: 0.75rem; }
+.admin-links-pending-row {
+ display: flex;
+ gap: 0.75rem;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 0.875rem;
+ border: 1px solid var(--border, #e5e7eb);
+ border-radius: 10px;
+ background: var(--background, #fff);
+}
+.admin-links-pending-row--warn {
+ border-color: #fcd34d;
+ background: #fffbeb;
+}
+.admin-links-pending-logo {
+ width: 48px;
+ height: 48px;
+}
+.admin-links-pending-main {
+ flex: 1;
+ min-width: 0;
+}
+.admin-links-pending-title {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem 0.75rem;
+ align-items: baseline;
+}
+.admin-links-pending-title a {
+ color: var(--j13-green);
+ font-size: 0.85rem;
+ word-break: break-all;
+}
+.admin-links-pending-reciprocal {
+ margin: 0.35rem 0 0;
+ font-size: 0.85rem;
+ color: var(--muted-fg, #666);
+ word-break: break-all;
+}
+.admin-links-pending-reciprocal a {
+ color: var(--j13-green);
+ margin-left: 0.25rem;
+}
+.admin-links-reciprocal-empty {
+ margin-left: 0.25rem;
+ color: var(--muted-fg, #999);
+}
+.admin-links-table-reciprocal {
+ max-width: 280px;
+ font-size: 12px;
+ word-break: break-all;
+}
+.admin-links-table-reciprocal a {
+ color: var(--j13-green);
+}
+.admin-links-pending-meta {
+ margin: 0.35rem 0 0;
+ font-size: 0.8rem;
+ color: var(--muted-fg, #888);
+}
+.admin-links-pending-actions {
+ display: flex;
+ gap: 0.35rem;
+ flex-shrink: 0;
+}
+
+.admin-links-table-site__head {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+}
+.admin-links-table-site__head strong {
+ display: block;
+ margin-bottom: 2px;
+}
+.admin-links-table-site__head a {
+ display: block;
+ font-size: 12px;
+ color: var(--j13-green);
+ word-break: break-all;
+}
+
+.admin-links-apply-detail__preview {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ padding: 12px;
+ border-radius: 10px;
+ background: var(--j13-green-soft, #edfbf3);
+ border: 1px solid color-mix(in srgb, var(--j13-green) 20%, var(--border, #e5e7eb));
+ margin-bottom: 12px;
+}
+.admin-links-apply-detail__preview .admin-links-logo-thumb {
+ width: 52px;
+ height: 52px;
+}
+.admin-links-apply-detail__preview strong {
+ display: block;
+ margin-bottom: 4px;
+}
+.admin-links-apply-detail__preview a {
+ font-size: 13px;
+ color: var(--j13-green);
+ word-break: break-all;
+}
+.admin-links-apply-detail__grid {
+ margin: 0;
+ display: grid;
+ gap: 10px;
+}
+.admin-links-apply-detail__grid > div {
+ display: grid;
+ grid-template-columns: 72px 1fr;
+ gap: 8px;
+ font-size: 13px;
+}
+.admin-links-apply-detail__grid dt {
+ margin: 0;
+ color: var(--muted-fg, #64748b);
+}
+.admin-links-apply-detail__grid dd {
+ margin: 0;
+ min-width: 0;
+ word-break: break-all;
+}
+.admin-links-apply-detail__logo-url {
+ font-size: 12px;
+}
+.admin-links-apply-dialog__footer {
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+@media (min-width: 1100px) {
+ .admin-main:has(.admin-links-page) {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ .admin-links-page {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ }
+ .admin-links-page .admin-links-page-head {
+ flex-shrink: 0;
+ }
+ .admin-links-columns {
+ flex: 1;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ align-items: stretch;
+ }
+ .admin-links-columns > .admin-card {
+ margin-bottom: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ }
+ .admin-links-columns > .admin-card > .admin-card-head,
+ .admin-links-columns > .admin-card > .admin-card-desc {
+ flex-shrink: 0;
+ }
+ .admin-links-apply-body,
+ .admin-links-columns .admin-links-editor {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ .admin-links-apply-body .admin-tabs,
+ .admin-links-columns .admin-links-editor__foot,
+ .admin-links-apply-body .admin-pagination {
+ flex-shrink: 0;
+ }
+ .admin-links-apply-body .admin-pagination {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px dashed var(--border, #e5e7eb);
+ }
+ .admin-links-apply-scroll,
+ .admin-links-published-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ }
+ .admin-links-columns .admin-links-table-head,
+ .admin-links-columns .admin-links-row {
+ grid-template-columns: 40px 40px minmax(52px, 0.5fr) minmax(64px, 0.75fr) minmax(120px, 2fr) 32px;
+ grid-template-areas: 'order preview name url logo actions';
+ gap: 6px 8px;
+ }
+ .admin-links-columns .admin-links-table-head {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ padding: 0 0 8px;
+ background: hsl(var(--card));
+ }
+ .admin-links-columns .admin-links-row {
+ padding: 8px 0;
+ align-items: center;
+ }
+ .admin-links-columns .admin-links-field--name {
+ max-width: none;
+ }
+ .admin-links-columns .admin-links-row__preview {
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ }
+ .admin-links-columns .admin-links-row__order {
+ flex-direction: column;
+ align-self: center;
+ padding-top: 0;
+ }
+ .admin-links-columns .admin-links-row__move {
+ flex-direction: column;
+ }
+}
+
+@media (max-width: 768px) {
+ .admin-links-page-head {
+ flex-direction: column;
+ align-items: stretch;
+ }
+ .admin-links-page-head__actions {
+ justify-content: flex-end;
+ }
+ .admin-links-table-head {
+ display: none;
+ }
+ .admin-links-row {
+ grid-template-columns: auto 32px 1fr 36px;
+ grid-template-areas:
+ 'order preview name actions'
+ 'order url url actions'
+ 'order logo logo actions';
+ gap: 6px 8px;
+ padding: 10px 0;
+ }
+ .admin-links-row__order {
+ grid-area: order;
+ flex-direction: column;
+ align-self: start;
+ padding-top: 4px;
+ }
+ .admin-links-row__move {
+ flex-direction: column;
+ }
+ .admin-links-row__preview {
+ grid-area: preview;
+ align-self: center;
+ }
+ .admin-links-field--name { grid-area: name; max-width: none; }
+ .admin-links-field--url { grid-area: url; }
+ .admin-links-field--logo { grid-area: logo; }
+ .admin-links-row__actions {
+ grid-area: actions;
+ align-self: center;
+ }
+ .admin-links-pending-row {
+ flex-direction: column;
+ }
+ .admin-links-pending-actions {
+ width: 100%;
+ justify-content: flex-end;
+ }
+}
+
+.admin-page-dialog { max-width: min(920px, 96vw); }
+
+/* 友情链接独立页 */
+.links-page-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 20px;
+}
+.links-page-board {
+ padding: 16px;
+ border-radius: 12px;
+ background: var(--j13-bg-block-muted);
+ border: 1px solid var(--j13-border-light);
+}
+.links-page-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
+ gap: 12px;
+}
+.links-page-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 16px 12px;
+ min-height: 118px;
+ border: 1px solid var(--j13-border-light);
+ border-radius: 10px;
+ background: var(--j13-bg-surface);
+ box-shadow: var(--j13-shadow-soft);
+ text-decoration: none;
+ color: inherit;
+ text-align: center;
+ transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
+}
+.links-page-card:hover {
+ border-color: color-mix(in srgb, var(--j13-green) 35%, var(--j13-border-light));
+ box-shadow: var(--j13-shadow-card);
+ transform: translateY(-1px);
+}
+.links-page-card__logo {
+ flex-shrink: 0;
+ width: 56px;
+ height: 56px;
+ border-radius: 12px;
+ overflow: hidden;
+ border: 1px solid var(--j13-border-light);
+ background: var(--j13-bg-block-muted);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ font-size: 18px;
+ color: var(--j13-green);
+}
+.links-page-card__logo img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+.links-page-card__name {
+ width: 100%;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 1.35;
+ word-break: break-word;
+}
+.links-page-empty {
+ margin: 24px 0;
+}
+.links-page-my {
+ margin-top: 36px;
+ padding-top: 24px;
+ border-top: 1px solid var(--border, #e5e7eb);
+}
+.links-page-my__title {
+ margin: 0 0 16px;
+ font-size: 18px;
+}
+.links-page-my__empty {
+ margin: 0;
+ color: var(--muted-fg, #64748b);
+ font-size: 14px;
+}
+.links-page-my-list {
+ display: grid;
+ gap: 12px;
+}
+.links-page-my-row {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ padding: 14px 16px;
+ border: 1px solid var(--j13-border-light);
+ border-radius: 12px;
+ background: var(--j13-bg-block-muted);
+}
+.links-page-my-row__logo {
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid var(--border, #e5e7eb);
+}
+.links-page-my-row__logo img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+.links-page-my-row__main {
+ flex: 1;
+ min-width: 0;
+}
+.links-page-my-row__title {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+.links-page-my-row__url {
+ display: block;
+ margin-top: 4px;
+ font-size: 13px;
+ color: var(--j13-green);
+ word-break: break-all;
+}
+.links-page-my-row__note {
+ margin: 6px 0 0;
+ font-size: 12px;
+ color: var(--muted-fg, #64748b);
+}
+.links-page-my-row__meta {
+ margin: 6px 0 0;
+ font-size: 12px;
+ color: var(--muted-fg, #94a3b8);
+}
+.links-page-my-row__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ flex-shrink: 0;
+}
+.links-page-login-hint {
+ margin-top: 24px;
+ font-size: 14px;
+ color: var(--muted-fg, #64748b);
+}
+.links-page-login-hint a {
+ margin: 0 4px;
+}
+
diff --git a/frontend/src/utils/asideWidgets.ts b/frontend/src/utils/asideWidgets.ts
new file mode 100644
index 0000000..ae39b78
--- /dev/null
+++ b/frontend/src/utils/asideWidgets.ts
@@ -0,0 +1,70 @@
+import type { AsideWidget, AsideWidgetId, ForumLimits, ForumLimitsPublic } from '../api/types';
+import { DEFAULT_ASIDE_WIDGETS } from '../api/types';
+
+const ASIDE_WIDGET_IDS: AsideWidgetId[] = ['tag_cloud', 'recent_comments', 'friend_links'];
+
+/** 从 limits 解析右侧栏组件列表(兼容仅有布尔开关的旧数据) */
+export function resolveAsideWidgets(
+ limits: Pick
,
+): AsideWidget[] {
+ if (limits.aside_widgets?.length) {
+ return normalizeAsideWidgets(limits.aside_widgets);
+ }
+ return [
+ { id: 'tag_cloud', enabled: limits.aside_show_tag_cloud },
+ { id: 'recent_comments', enabled: limits.aside_show_recent_comments },
+ { id: 'friend_links', enabled: limits.aside_show_friend_links },
+ ];
+}
+
+/** 校验并补全右侧栏组件列表 */
+export function normalizeAsideWidgets(widgets: AsideWidget[]): AsideWidget[] {
+ const seen = new Set();
+ const out: AsideWidget[] = [];
+ for (const w of widgets) {
+ if (!ASIDE_WIDGET_IDS.includes(w.id) || seen.has(w.id)) continue;
+ seen.add(w.id);
+ out.push({ id: w.id, enabled: !!w.enabled });
+ }
+ for (const id of ASIDE_WIDGET_IDS) {
+ if (!seen.has(id)) out.push({ id, enabled: false });
+ }
+ return out;
+}
+
+/** 将 aside_widgets 同步回 ForumLimits 布尔字段 */
+export function syncAsideBoolsFromWidgets(widgets: AsideWidget[]): Pick {
+ const normalized = normalizeAsideWidgets(widgets);
+ return {
+ aside_show_tag_cloud: normalized.find(w => w.id === 'tag_cloud')?.enabled ?? false,
+ aside_show_recent_comments: normalized.find(w => w.id === 'recent_comments')?.enabled ?? false,
+ aside_show_friend_links: normalized.find(w => w.id === 'friend_links')?.enabled ?? true,
+ };
+}
+
+/** 合并右侧栏组件到论坛限制(保存 API 时使用) */
+export function mergeForumLimitsWithAsideWidgets(limits: ForumLimits, widgets: AsideWidget[]): ForumLimits {
+ const normalized = normalizeAsideWidgets(widgets);
+ return {
+ ...limits,
+ aside_widgets: normalized,
+ ...syncAsideBoolsFromWidgets(normalized),
+ };
+}
+
+/** 保存后优先采用服务端返回的 aside_widgets,缺失时保留本次提交值 */
+export function resolveSavedAsideWidgets(saved: AsideWidget[], response?: AsideWidget[] | null): AsideWidget[] {
+ if (response?.length) return normalizeAsideWidgets(response);
+ return normalizeAsideWidgets(saved);
+}
+
+export function isAsideWidgetEnabled(widgets: AsideWidget[], id: AsideWidgetId): boolean {
+ return resolveAsideWidgets({
+ aside_widgets: widgets,
+ aside_show_tag_cloud: false,
+ aside_show_recent_comments: false,
+ aside_show_friend_links: false,
+ }).find(w => w.id === id)?.enabled ?? false;
+}
+
+export { DEFAULT_ASIDE_WIDGETS };
diff --git a/frontend/src/utils/board.ts b/frontend/src/utils/board.ts
index c3484eb..91c5959 100644
--- a/frontend/src/utils/board.ts
+++ b/frontend/src/utils/board.ts
@@ -1,3 +1,5 @@
+import type { Board } from '../api/types';
+
/** 板块图标背景色 */
const BOARD_COLORS = ['#2d8a55', '#3498db', '#9b59b6', '#e67e22', '#1abc9c', '#e74c3c', '#34495e'];
@@ -8,3 +10,30 @@ export function boardColor(id: number) {
export function boardInitial(name: string) {
return (name?.trim()?.[0] || '?').toUpperCase();
}
+
+/** 是否为公告类板块(名称含「公告」或 megaphone 图标) */
+function isAnnouncementBoard(board: Board): boolean {
+ if (board.name.includes('公告')) return true;
+ return (board.icon || '').trim() === 'megaphone';
+}
+
+/** 是否为闲聊类板块(名称含「闲聊」) */
+function isCasualBoard(board: Board): boolean {
+ return board.name.includes('闲聊');
+}
+
+/**
+ * 发帖页板块排序:闲聊置顶、公告置底,其余保持 API 原序。
+ * 仅用于发帖选择器,不影响侧栏/导航排序。
+ */
+export function sortBoardsForCompose(boards: Board[]): Board[] {
+ const casual: Board[] = [];
+ const middle: Board[] = [];
+ const announcement: Board[] = [];
+ for (const b of boards) {
+ if (isCasualBoard(b)) casual.push(b);
+ else if (isAnnouncementBoard(b)) announcement.push(b);
+ else middle.push(b);
+ }
+ return [...casual, ...middle, ...announcement];
+}
diff --git a/frontend/src/utils/bounty.ts b/frontend/src/utils/bounty.ts
new file mode 100644
index 0000000..46845dc
--- /dev/null
+++ b/frontend/src/utils/bounty.ts
@@ -0,0 +1,29 @@
+import type { Comment } from '../api/types';
+import type { CommentNode } from '../utils/comment';
+
+/** 评论树中是否包含指定评论 ID */
+export function commentTreeContains(node: CommentNode, commentId: number): boolean {
+ if (node.comment.id === commentId) return true;
+ return node.children.some(child => commentTreeContains(child, commentId));
+}
+
+/** 将含被采纳评论的根楼层置顶 */
+export function pinAwardedCommentTree(
+ tree: CommentNode[],
+ awardedCommentId?: number,
+): CommentNode[] {
+ if (!awardedCommentId) return tree;
+ const idx = tree.findIndex(node => commentTreeContains(node, awardedCommentId));
+ if (idx <= 0) return tree;
+ const next = [...tree];
+ const [node] = next.splice(idx, 1);
+ next.unshift(node);
+ return next;
+}
+
+/** 根据评论 ID 查找楼层号 */
+export function findCommentFloor(comments: Comment[], commentId?: number): number | null {
+ if (!commentId) return null;
+ const hit = comments.find(c => c.id === commentId);
+ return hit?.floor ?? null;
+}
diff --git a/frontend/src/utils/composeDraft.ts b/frontend/src/utils/composeDraft.ts
index 4b5849c..bba7cb8 100644
--- a/frontend/src/utils/composeDraft.ts
+++ b/frontend/src/utils/composeDraft.ts
@@ -1,30 +1,59 @@
/** 新建帖本地草稿(localStorage) */
-const STORAGE_KEY = 'j13-compose-draft-v1';
+const STORAGE_KEY = 'j13-compose-draft-v2';
+
+export type ComposeDraftPostType = 'normal' | 'question' | 'poll' | 'bounty' | 'lottery';
export type ComposeDraft = {
title: string;
tags: string;
content: string;
boardId: string;
- postType: 'normal' | 'question';
+ postType: ComposeDraftPostType;
+ pollOptions?: string[];
+ pollMulti?: boolean;
+ pollMaxChoices?: number;
+ pollEndsAt?: string;
+ pollNoEndTime?: boolean;
savedAt: number;
};
+const VALID_POST_TYPES: ComposeDraftPostType[] = ['normal', 'question', 'poll', 'bounty', 'lottery'];
+
+function normalizePostType(value: unknown): ComposeDraftPostType {
+ if (typeof value === 'string' && VALID_POST_TYPES.includes(value as ComposeDraftPostType)) {
+ return value as ComposeDraftPostType;
+ }
+ return 'normal';
+}
+
export function loadComposeDraft(): ComposeDraft | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const data = JSON.parse(raw) as Partial;
if (!data || typeof data !== 'object') return null;
- return {
+ const postType = normalizePostType(data.postType);
+ const draft: ComposeDraft = {
title: typeof data.title === 'string' ? data.title : '',
tags: typeof data.tags === 'string' ? data.tags : '',
content: typeof data.content === 'string' ? data.content : '',
boardId: typeof data.boardId === 'string' ? data.boardId : '',
- postType: data.postType === 'question' ? 'question' : 'normal',
+ postType,
savedAt: typeof data.savedAt === 'number' ? data.savedAt : Date.now(),
};
+ if (postType === 'poll') {
+ if (Array.isArray(data.pollOptions) && data.pollOptions.every(o => typeof o === 'string')) {
+ draft.pollOptions = data.pollOptions.length >= 2 ? data.pollOptions : ['', ''];
+ }
+ if (typeof data.pollMulti === 'boolean') draft.pollMulti = data.pollMulti;
+ if (typeof data.pollMaxChoices === 'number' && data.pollMaxChoices > 0) {
+ draft.pollMaxChoices = data.pollMaxChoices;
+ }
+ if (typeof data.pollEndsAt === 'string') draft.pollEndsAt = data.pollEndsAt;
+ if (typeof data.pollNoEndTime === 'boolean') draft.pollNoEndTime = data.pollNoEndTime;
+ }
+ return draft;
} catch {
return null;
}
@@ -42,6 +71,8 @@ export function saveComposeDraft(draft: Omit): void {
export function clearComposeDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);
+ // 清理旧版草稿键
+ localStorage.removeItem('j13-compose-draft-v1');
} catch {
// ignore
}
@@ -50,10 +81,14 @@ export function clearComposeDraft(): void {
/** 草稿是否有实质内容 */
export function composeDraftHasContent(d: ComposeDraft | null | undefined): boolean {
if (!d) return false;
+ const hasPollOptions = d.postType === 'poll'
+ && Array.isArray(d.pollOptions)
+ && d.pollOptions.some(o => o.trim());
return !!(
d.title.trim()
|| d.tags.trim()
|| d.content.trim()
|| (d.content && d.content.replace(/<[^>]*>/g, '').trim())
+ || hasPollOptions
);
}
diff --git a/frontend/src/utils/friendLink.ts b/frontend/src/utils/friendLink.ts
new file mode 100644
index 0000000..730bb64
--- /dev/null
+++ b/frontend/src/utils/friendLink.ts
@@ -0,0 +1,38 @@
+import type { FriendLinkApply } from '../api/types';
+
+/** 解析友链 LOGO 为可加载的 URL(相对路径补全为当前站点 origin) */
+export function resolveFriendLinkLogo(logo?: string, siteURL?: string): string {
+ const raw = logo?.trim() || '';
+ if (!raw) return '';
+ if (/^https?:\/\//i.test(raw)) return raw;
+ if (raw.startsWith('/')) {
+ const base = siteURL?.trim() || (typeof window !== 'undefined' ? window.location.origin : '');
+ return base ? `${base.replace(/\/$/, '')}${raw}` : raw;
+ }
+ return raw;
+}
+
+/** 回链检测是否仍在后台进行中 */
+export function isReciprocalChecking(apply: FriendLinkApply): boolean {
+ if (apply.status !== 'pending') return false;
+ if (apply.reciprocal_checked_at) return false;
+ if (apply.reciprocal_verified) return false;
+ if (apply.reciprocal_check_note?.trim()) return false;
+ return true;
+}
+
+export function reciprocalStatusLabel(apply: FriendLinkApply): {
+ text: string;
+ variant: 'green' | 'orange' | 'secondary';
+} {
+ if (isReciprocalChecking(apply)) {
+ return { text: '检测中…', variant: 'secondary' };
+ }
+ if (apply.reciprocal_verified) {
+ return { text: '回链已检测到', variant: 'green' };
+ }
+ if (apply.reciprocal_check_note?.trim()) {
+ return { text: apply.reciprocal_check_note, variant: 'orange' };
+ }
+ return { text: '未检测', variant: 'secondary' };
+}
diff --git a/frontend/src/utils/layoutCache.ts b/frontend/src/utils/layoutCache.ts
index fd6ee93..c984040 100644
--- a/frontend/src/utils/layoutCache.ts
+++ b/frontend/src/utils/layoutCache.ts
@@ -1,8 +1,7 @@
-import type { Board, ForumStats, RecentComment, PostItem, TagCount } from '../api/types';
+import type { Board, ForumStats, RecentComment, TagCount } from '../api/types';
const BOARDS_KEY = 'j13-cache-boards';
const STATS_KEY = 'j13-cache-stats';
-const HOT_KEY = 'j13-cache-hot';
const RECENT_COMMENTS_KEY = 'j13-cache-recent-comments';
const TAGS_KEY = 'j13-cache-tags';
@@ -33,12 +32,6 @@ export function getCachedStats(): ForumStats | null {
return readJson(STATS_KEY);
}
-/** 读取缓存的热门帖子,避免右栏/抽屉首屏高度跳动 */
-export function getCachedHot(): PostItem[] {
- const list = readJson(HOT_KEY);
- return Array.isArray(list) ? list : [];
-}
-
/** 读取缓存的最新评论,避免右栏/抽屉首屏高度跳动 */
export function getCachedRecentComments(): RecentComment[] {
const list = readJson(RECENT_COMMENTS_KEY);
@@ -54,7 +47,7 @@ export function getCachedTags(): TagCount[] {
/** 右栏是否已有可展示的 session 缓存(含空列表) */
export function hasCachedAside(): boolean {
try {
- return sessionStorage.getItem(HOT_KEY) != null || sessionStorage.getItem(RECENT_COMMENTS_KEY) != null;
+ return sessionStorage.getItem(RECENT_COMMENTS_KEY) != null;
} catch {
return false;
}
@@ -68,10 +61,6 @@ export function setCachedStats(stats: ForumStats) {
writeJson(STATS_KEY, stats);
}
-export function setCachedHot(posts: PostItem[]) {
- writeJson(HOT_KEY, posts);
-}
-
export function setCachedRecentComments(list: RecentComment[]) {
writeJson(RECENT_COMMENTS_KEY, list);
}
diff --git a/frontend/src/utils/permalink.ts b/frontend/src/utils/permalink.ts
index 2c790fd..cc1c1db 100644
--- a/frontend/src/utils/permalink.ts
+++ b/frontend/src/utils/permalink.ts
@@ -30,6 +30,25 @@ export function userPath(id: number | string, opts?: PermalinkOpts): string {
return `/user/${id}${suffix(opts)}`;
}
+/** 板块规范路径:/board/123 或 /board/123.html */
+export function boardPath(id: number | string, opts?: PermalinkOpts): string {
+ return `/board/${id}${suffix(opts)}`;
+}
+
+/** 自定义单页规范路径 */
+export function pagePath(slug: string, opts?: PermalinkOpts): string {
+ const s = slug.trim().toLowerCase();
+ if (!s) return '/';
+ return `/page/${s}${suffix(opts)}`;
+}
+
+/** 从 slug 路由参数解析(兼容 about / about.html) */
+export function parsePermalinkSlug(raw: string | undefined): string {
+ if (!raw) return '';
+ const m = String(raw).match(/^([a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9])(?:\.[A-Za-z0-9]{1,16})?$/i);
+ return m ? m[1].toLowerCase() : '';
+}
+
/** 从路由参数解析数字 ID(兼容 123 / 123.html) */
export function parsePermalinkID(raw: string | undefined): number {
if (!raw) return NaN;
@@ -39,13 +58,17 @@ export function parsePermalinkID(raw: string | undefined): number {
/** 客户端:若当前 URL 非规范伪静态路径则返回应跳转的目标 */
export function canonicalRedirectPath(
- kind: 'post' | 'user',
+ kind: 'post' | 'user' | 'board',
id: number,
currentPathname: string,
opts?: PermalinkOpts,
): string | null {
if (!id || Number.isNaN(id)) return null;
- const target = kind === 'post' ? postPath(id, opts) : userPath(id, opts);
+ const target = kind === 'post'
+ ? postPath(id, opts)
+ : kind === 'user'
+ ? userPath(id, opts)
+ : boardPath(id, opts);
const cur = currentPathname.replace(/\/$/, '') || '/';
const want = target.replace(/\/$/, '') || '/';
return cur === want ? null : target;
diff --git a/frontend/src/utils/sortOrder.ts b/frontend/src/utils/sortOrder.ts
new file mode 100644
index 0000000..ff937ca
--- /dev/null
+++ b/frontend/src/utils/sortOrder.ts
@@ -0,0 +1,69 @@
+import { arrayMove } from '@dnd-kit/sortable';
+
+export const ADMIN_SORTABLE_MOVE_BUTTONS_THRESHOLD = 8;
+
+export function reorderItems(items: T[], from: number, to: number): T[] {
+ if (from < 0 || to < 0 || from >= items.length || to >= items.length || from === to) {
+ return items;
+ }
+ return arrayMove(items, from, to);
+}
+
+export function assignSortOrders(items: T[]): T[] {
+ return items.map((item, index) => ({ ...item, sort_order: index + 1 }));
+}
+
+export function diffSortOrderChanges(
+ before: T[],
+ after: T[],
+): Array<{ id: number; sort_order: number }> {
+ const beforeMap = new Map(before.map(item => [item.id, item.sort_order ?? 0]));
+ return after
+ .filter(item => beforeMap.get(item.id) !== item.sort_order)
+ .map(item => ({ id: item.id, sort_order: item.sort_order ?? 0 }));
+}
+
+/** 将子集重排结果合并回完整列表,并重算 sort_order */
+export function mergeReorderedSubset(
+ all: T[],
+ subsetBefore: T[],
+ subsetAfter: T[],
+): T[] {
+ const sorted = [...all].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id - b.id);
+ const subsetIds = new Set(subsetBefore.map(item => item.id));
+ const result: T[] = [];
+ let subsetIdx = 0;
+ for (const item of sorted) {
+ if (subsetIds.has(item.id)) {
+ if (subsetIdx < subsetAfter.length) {
+ result.push(subsetAfter[subsetIdx++]);
+ }
+ } else {
+ result.push(item);
+ }
+ }
+ return assignSortOrders(result);
+}
+
+export async function persistSortOrderChanges(
+ before: T[],
+ reordered: T[],
+ updateItem: (item: T) => Promise,
+): Promise {
+ const after = assignSortOrders(reordered);
+ const changes = diffSortOrderChanges(before, after);
+ for (const change of changes) {
+ const item = after.find(row => row.id === change.id);
+ if (item) await updateItem(item);
+ }
+ return after;
+}
+
+export function shouldShowSortableMoveButtons(
+ count: number,
+ mode: boolean | 'auto' = 'auto',
+): boolean {
+ if (mode === true) return true;
+ if (mode === false) return false;
+ return count > ADMIN_SORTABLE_MOVE_BUTTONS_THRESHOLD;
+}
diff --git a/handler/api.go b/handler/api.go
index d351333..b14bed4 100644
--- a/handler/api.go
+++ b/handler/api.go
@@ -70,12 +70,16 @@ func (h *Handlers) APIHealth(c *gin.Context) {
// APIStats 论坛概览统计
func (h *Handlers) APIStats(c *gin.Context) {
- var userCount, postCount, boardCount int64
+ var userCount, postCount, boardCount, commentCount int64
model.DB.Model(&model.User{}).Count(&userCount)
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount)
+ model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&commentCount)
c.JSON(http.StatusOK, gin.H{
- "users": userCount, "posts": postCount, "boards": boardCount,
+ "users": userCount,
+ "posts": postCount,
+ "boards": boardCount,
+ "comments": commentCount,
})
}
@@ -147,6 +151,7 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
pendingPosts, _ := h.Post.PendingPostCount()
pendingComments, _ := h.Comment.PendingCommentCount()
pendingReports, _ := h.Report.PendingCount()
+ pendingFriendLinks, _ := h.FriendLinkApply.PendingCount()
recentPosts, _, _ := h.Post.List(service.PostListQuery{
Page: 1, Size: 8, ViewerIsAdmin: true, Status: "all",
})
@@ -159,6 +164,7 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
"pending_posts": pendingPosts,
"pending_comments": pendingComments,
"pending_reports": pendingReports,
+ "pending_friend_links": pendingFriendLinks,
"recent_posts": recentPosts,
})
}
@@ -575,7 +581,9 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) {
// APISiteBranding 前台公开的站点品牌配置
func (h *Handlers) APISiteBranding(c *gin.Context) {
- c.JSON(http.StatusOK, h.Settings.SiteBranding())
+ brand := h.Settings.SiteBranding()
+ brand.SiteURL = h.publicBaseURL(c)
+ c.JSON(http.StatusOK, brand)
}
// APIAdminUpdateBranding 更新站点品牌文案
@@ -1039,7 +1047,7 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
editReason = h.Post.UserEditBlockReason(post, uid, isAdmin)
}
isEdited := post.UpdatedAt.Sub(post.CreatedAt) > time.Minute
- c.JSON(http.StatusOK, gin.H{
+ resp := gin.H{
"post": post,
"comment_count": len(comments),
"liked": h.Post.IsLiked(uid, uint(id)),
@@ -1049,7 +1057,26 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
"edit_block_reason": editReason,
"is_edited": isEdited,
"post_edit_window_hours": h.Settings.PostEditWindowHours(),
- })
+ }
+ if post.PostType == model.PostTypePoll {
+ if poll, err := service.GetPollView(uint(id), uid); err == nil {
+ resp["poll"] = poll
+ }
+ }
+ if post.PostType == model.PostTypeLottery {
+ if lottery, err := service.GetPostLotteryView(post); err == nil && lottery != nil {
+ resp["lottery"] = lottery
+ }
+ }
+ if post.PostType == model.PostTypeBounty && post.BountyStatus == model.BountyStatusOpen && post.BountyPoints > 0 {
+ canRefund, blockReason := service.CanRefundBounty(post, isAdmin)
+ resp["bounty_can_refund"] = canRefund
+ resp["bounty_refund_block_reason"] = blockReason
+ if n, err := service.CountEligibleBountyReplies(model.DB, post.ID, post.UserID); err == nil {
+ resp["bounty_eligible_reply_count"] = n
+ }
+ }
+ c.JSON(http.StatusOK, resp)
}
// APIPostComments 楼层列表
diff --git a/handler/economy.go b/handler/economy.go
index e97d2a6..db9b96c 100644
--- a/handler/economy.go
+++ b/handler/economy.go
@@ -39,6 +39,16 @@ func (h *Handlers) APIMePoints(c *gin.Context) {
})
}
+// APIMeCheckInGet 今日签到状态
+func (h *Handlers) APIMeCheckInGet(c *gin.Context) {
+ st, err := h.Points.GetCheckInStatus(h.currentUserID(c))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"check_in": st})
+}
+
// APIMeCheckIn 每日签到
func (h *Handlers) APIMeCheckIn(c *gin.Context) {
st, err := h.Points.CheckIn(h.currentUserID(c))
diff --git a/handler/friend_link.go b/handler/friend_link.go
new file mode 100644
index 0000000..ba842f2
--- /dev/null
+++ b/handler/friend_link.go
@@ -0,0 +1,244 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "git.iioio.com/freefire/jiang13-forum/service"
+ "github.com/gin-gonic/gin"
+)
+
+// APIApplyFriendLink 提交友情链接申请
+func (h *Handlers) APIApplyFriendLink(c *gin.Context) {
+ uid := h.currentUserID(c)
+ if uid == 0 {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
+ return
+ }
+ var req struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Logo string `json:"logo"`
+ LinkOnHomepage bool `json:"link_on_homepage"`
+ ReciprocalPageURL string `json:"reciprocal_page_url"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ result, err := h.FriendLinkApply.Create(service.FriendLinkApplyInput{
+ UserID: uid,
+ Name: req.Name,
+ URL: req.URL,
+ Logo: req.Logo,
+ LinkOnHomepage: req.LinkOnHomepage,
+ ReciprocalPageURL: req.ReciprocalPageURL,
+ OurSiteURL: h.publicBaseURL(c),
+ })
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ resp := gin.H{
+ "message": friendLinkApplySubmittedMessage(h.Settings.FriendLinkReciprocalCheckEnabled(), false),
+ "apply": result.Apply,
+ }
+ c.JSON(http.StatusOK, resp)
+}
+
+// APIUploadFriendLinkLogo 上传友链申请 LOGO
+func (h *Handlers) APIUploadFriendLinkLogo(c *gin.Context) {
+ uid := h.currentUserID(c)
+ if uid == 0 {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
+ return
+ }
+ file, err := c.FormFile("logo")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请选择 LOGO 图片"})
+ return
+ }
+ maxBytes := int64(h.Settings.AvatarMaxMB()) * 1024 * 1024
+ if file.Size > maxBytes {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大"})
+ return
+ }
+ url, err := service.SaveUploadedImage(
+ h.Store,
+ file,
+ service.UploadCategorySite,
+ fmt.Sprintf("fl_%d", uid),
+ )
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "LOGO 已上传", "url": url})
+}
+
+// APIAdminFriendLinkApplies 管理员友链申请列表
+func (h *Handlers) APIAdminFriendLinkApplies(c *gin.Context) {
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
+ status := strings.TrimSpace(c.DefaultQuery("status", "pending"))
+ list, total, err := h.FriendLinkApply.ListAdmin(service.FriendLinkApplyListQuery{
+ Page: page, Size: size, Status: status,
+ })
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ pending, _ := h.FriendLinkApply.PendingCount()
+ c.JSON(http.StatusOK, gin.H{
+ "applies": list,
+ "total": total,
+ "page": page,
+ "pending_count": pending,
+ "reciprocal_check_enabled": h.Settings.FriendLinkReciprocalCheckEnabled(),
+ })
+}
+
+// APIAdminUpdateFriendLinkSettings 更新友链回链检测开关
+func (h *Handlers) APIAdminUpdateFriendLinkSettings(c *gin.Context) {
+ var req struct {
+ ReciprocalCheckEnabled *bool `json:"reciprocal_check_enabled"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil || req.ReciprocalCheckEnabled == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if err := h.Settings.SetFriendLinkReciprocalCheckEnabled(*req.ReciprocalCheckEnabled); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ enabled := *req.ReciprocalCheckEnabled
+ msg := "已开启回链检测"
+ if !enabled {
+ msg = "已关闭回链检测"
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "message": msg,
+ "reciprocal_check_enabled": enabled,
+ })
+}
+
+// APIAdminApproveFriendLinkApply 通过友链申请
+func (h *Handlers) APIAdminApproveFriendLinkApply(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ apply, err := h.FriendLinkApply.Approve(uint(id))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "已通过并加入友情链接", "apply": apply})
+}
+
+// APIAdminRejectFriendLinkApply 拒绝友链申请
+func (h *Handlers) APIAdminRejectFriendLinkApply(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ var req struct {
+ Note string `json:"note"`
+ }
+ _ = c.ShouldBindJSON(&req)
+ apply, err := h.FriendLinkApply.Reject(uint(id), req.Note)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "已拒绝申请", "apply": apply})
+}
+
+// APIMyFriendLinkApplies 当前用户的友链申请列表
+func (h *Handlers) APIMyFriendLinkApplies(c *gin.Context) {
+ uid := h.currentUserID(c)
+ if uid == 0 {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
+ return
+ }
+ list, err := h.FriendLinkApply.ListMine(uid)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"applies": list})
+}
+
+// APICancelFriendLinkApply 撤销待审友链申请
+func (h *Handlers) APICancelFriendLinkApply(c *gin.Context) {
+ uid := h.currentUserID(c)
+ if uid == 0 {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err := h.FriendLinkApply.Cancel(uid, uint(id)); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "已撤销申请"})
+}
+
+// APIUpdateFriendLinkApply 修改并重新提交友链申请
+func (h *Handlers) APIUpdateFriendLinkApply(c *gin.Context) {
+ uid := h.currentUserID(c)
+ if uid == 0 {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
+ return
+ }
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ var req struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Logo string `json:"logo"`
+ LinkOnHomepage bool `json:"link_on_homepage"`
+ ReciprocalPageURL string `json:"reciprocal_page_url"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ result, err := h.FriendLinkApply.Update(uid, uint(id), service.FriendLinkApplyInput{
+ UserID: uid,
+ Name: req.Name,
+ URL: req.URL,
+ Logo: req.Logo,
+ LinkOnHomepage: req.LinkOnHomepage,
+ ReciprocalPageURL: req.ReciprocalPageURL,
+ OurSiteURL: h.publicBaseURL(c),
+ })
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ resp := gin.H{
+ "message": friendLinkApplySubmittedMessage(h.Settings.FriendLinkReciprocalCheckEnabled(), true),
+ "apply": result.Apply,
+ }
+ c.JSON(http.StatusOK, resp)
+}
+
+// APIAdminRecheckFriendLinkApply 管理员重新检测回链
+func (h *Handlers) APIAdminRecheckFriendLinkApply(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ apply, err := h.FriendLinkApply.RecheckReciprocal(uint(id), h.publicBaseURL(c))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "已开始重新检测回链", "apply": apply})
+}
+
+func friendLinkApplySubmittedMessage(checkEnabled, isUpdate bool) string {
+ if isUpdate {
+ if checkEnabled {
+ return "申请已更新,回链检测将在后台进行"
+ }
+ return "申请已更新"
+ }
+ if checkEnabled {
+ return "申请已提交,回链检测将在后台进行"
+ }
+ return "申请已提交"
+}
diff --git a/handler/handlers.go b/handler/handlers.go
index aa4b3d8..dbb47c9 100644
--- a/handler/handlers.go
+++ b/handler/handlers.go
@@ -38,6 +38,8 @@ type Handlers struct {
Gitea *service.GiteaService
Points *service.PointsService
Badge *service.BadgeService
+ SitePage *service.SitePageService
+ FriendLinkApply *service.FriendLinkApplyService
}
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
@@ -419,6 +421,18 @@ func (h *Handlers) APICreatePost(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
+ extras := service.ParsePostExtrasFromForm(
+ c.PostForm("poll_options"),
+ c.PostForm("bounty_points"),
+ c.PostForm("lottery_winner_count"),
+ )
+ if post.PostType == model.PostTypePoll || post.PostType == model.PostTypeBounty || post.PostType == model.PostTypeLottery {
+ if err := service.FinalizeSpecialPostCreate(post, h.currentUserID(c), extras); err != nil {
+ _ = h.Post.Delete(h.currentUserID(c), post.ID, true)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ }
msg := "发帖成功"
if post.Status == model.ContentStatusPending {
msg = "已提交审核,通过后将公开显示"
diff --git a/handler/seo.go b/handler/seo.go
index d502cc3..3e789e7 100644
--- a/handler/seo.go
+++ b/handler/seo.go
@@ -2,6 +2,8 @@ package handler
import (
"encoding/json"
+ "fmt"
+ "html"
"net/http"
"regexp"
"strconv"
@@ -15,8 +17,7 @@ import (
)
var (
- seoPostEditRe = regexp.MustCompile(`^/post/(\d+)/edit/?$`)
- seoBoardPathRe = regexp.MustCompile(`^/board/(\d+)/?$`)
+ seoPostEditRe = regexp.MustCompile(`^/post/(\d+)/edit/?$`)
)
const (
@@ -58,15 +59,17 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
}
now := time.Now().UTC()
+ permalink := h.Settings.Permalink()
urls := []service.SitemapURL{
{Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
{Loc: base + "/projects", LastMod: now, ChangeFreq: "daily", Priority: "0.6"},
+ {Loc: base + "/links", LastMod: now, ChangeFreq: "weekly", Priority: "0.6"},
}
if boards, err := h.Board.List(); err == nil {
for _, board := range boards {
urls = append(urls, service.SitemapURL{
- Loc: base + service.QueryBoardHome(board.ID),
+ Loc: base + service.QueryBoardHome(board.ID, permalink),
LastMod: board.UpdatedAt.UTC(),
ChangeFreq: "daily",
Priority: "0.7",
@@ -74,8 +77,7 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
}
}
- permalink := h.Settings.Permalink()
- if posts, err := h.Post.ListSitemap(seoSitemapLimit); err == nil {
+ if posts, e1 := h.Post.ListSitemap(seoSitemapLimit); e1 == nil {
for _, p := range posts {
lm := p.UpdatedAt
if lm.IsZero() {
@@ -90,7 +92,7 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
}
}
- if users, err := h.User.ListSitemap(seoSitemapLimit); err == nil {
+ if users, e2 := h.User.ListSitemap(seoSitemapLimit); e2 == nil {
for _, u := range users {
urls = append(urls, service.SitemapURL{
Loc: base + permalink.UserPath(u.ID),
@@ -101,6 +103,21 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
}
}
+ if pages, e3 := h.SitePage.ListSitemap(seoSitemapLimit); e3 == nil {
+ for _, p := range pages {
+ lm := p.UpdatedAt
+ if lm.IsZero() {
+ lm = p.CreatedAt
+ }
+ urls = append(urls, service.SitemapURL{
+ Loc: base + permalink.PagePath(p.Slug),
+ LastMod: lm.UTC(),
+ ChangeFreq: "monthly",
+ Priority: "0.5",
+ })
+ }
+ }
+
var b strings.Builder
b.WriteString(``)
b.WriteString(``)
@@ -137,11 +154,6 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
func (h *Handlers) ServePublicSPA(c *gin.Context) {
path := c.Request.URL.Path
- if m := seoBoardPathRe.FindStringSubmatch(path); len(m) == 2 {
- c.Redirect(http.StatusMovedPermanently, "/?board="+m[1])
- return
- }
-
brand := h.Settings.SiteBranding()
base := h.publicBaseURL(c)
siteName := strings.TrimSpace(brand.Name)
@@ -152,11 +164,59 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
siteKeywords := brand.MetaKeywords()
permalink := h.Settings.Permalink()
+ // 旧版 /?board=id → 规范板块路径
+ if path == "/" || path == "" {
+ if boardID, err := strconv.ParseUint(c.Query("board"), 10, 64); err == nil && boardID > 0 {
+ target := service.QueryBoardHome(uint(boardID), permalink)
+ if q := c.Request.URL.RawQuery; q != "" {
+ // 保留 sort/keyword 等 query,去掉 board
+ vals := c.Request.URL.Query()
+ vals.Del("board")
+ if rest := vals.Encode(); rest != "" {
+ target += "?" + rest
+ }
+ }
+ c.Redirect(http.StatusMovedPermanently, target)
+ return
+ }
+ }
+
isBot := service.IsSEOCrawler(c.Request.UserAgent())
if isBot {
c.Header("Vary", "User-Agent")
}
+ // 板块首页(含可选伪静态后缀)
+ if bm := permalink.MatchBoardPath(path); bm.OK {
+ if bm.NeedsCanonicalRedirect(path) {
+ c.Redirect(http.StatusMovedPermanently, bm.Canonical+preserveQueryExceptBoard(c))
+ return
+ }
+ board, err := h.Board.GetByID(bm.ID)
+ if err != nil {
+ h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
+ return
+ }
+ desc := strings.TrimSpace(board.Description)
+ if desc == "" {
+ desc = brand.MetaDescription()
+ }
+ meta := attachSiteSEO(&embed_static.SPAPageMeta{
+ Title: pageTitle(board.Name, siteName),
+ Description: service.TruncateRunes(desc, seoDescMax),
+ Keywords: service.JoinSEOKeywords(board.Name, siteKeywords),
+ Canonical: service.AbsoluteURL(base, bm.Canonical),
+ OGType: "website",
+ OGImage: defaultImage,
+ }, siteName, siteKeywords)
+ if isBot {
+ c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botBoardHTML(meta, *board)))
+ return
+ }
+ embed_static.ServeSPAWithMeta(c, meta)
+ return
+ }
+
// 帖子详情(含可选伪静态后缀)
if pm := permalink.MatchPostPath(path); pm.OK {
if pm.NeedsCanonicalRedirect(path) {
@@ -196,6 +256,35 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
return
}
+ // 自定义单页
+ if pg := permalink.MatchPagePath(path); pg.OK {
+ if strings.TrimSuffix(path, "/") != strings.TrimSuffix(pg.Canonical, "/") {
+ c.Redirect(http.StatusMovedPermanently, pg.Canonical)
+ return
+ }
+ page, err := h.SitePage.GetBySlug(pg.Slug, h.isAdmin(c))
+ if err != nil {
+ h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
+ return
+ }
+ desc := service.ExcerptFromHTML(page.Content, seoDescMax)
+ meta := attachSiteSEO(&embed_static.SPAPageMeta{
+ Title: pageTitle(page.Title, siteName),
+ Description: desc,
+ Keywords: service.JoinSEOKeywords(page.Title, siteKeywords),
+ Canonical: service.AbsoluteURL(base, pg.Canonical),
+ OGType: "article",
+ OGImage: defaultImage,
+ }, siteName, siteKeywords)
+ if isBot {
+ body := fmt.Sprintf(`%s
%s
`, html.EscapeString(page.Title), page.Content)
+ c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderBotHTML(meta, body)))
+ return
+ }
+ embed_static.ServeSPAWithMeta(c, meta)
+ return
+ }
+
// 未知路径 → 404
if !isKnownPublicPath(path) {
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
@@ -246,12 +335,19 @@ func attachSiteSEO(meta *embed_static.SPAPageMeta, siteName, keywords string) *e
func isKnownPublicPath(path string) bool {
switch path {
- case "/", "/login", "/register", "/compose", "/profile", "/favorites", "/projects", "/boards":
+ case "/", "/login", "/register", "/compose", "/profile", "/favorites", "/projects", "/links", "/boards":
return true
}
if seoPostEditRe.MatchString(path) {
return true
}
+ permalink := service.PermalinkConfig{}
+ if permalink.MatchBoardPath(path).OK {
+ return true
+ }
+ if permalink.MatchPagePath(path).OK {
+ return true
+ }
return false
}
@@ -284,7 +380,7 @@ func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.S
}
meta.Title = pageTitle(board.Name, siteName)
meta.Description = service.TruncateRunes(desc, seoDescMax)
- meta.Canonical = service.AbsoluteURL(base, service.QueryBoardHome(board.ID))
+ meta.Canonical = service.AbsoluteURL(base, service.QueryBoardHome(board.ID, h.Settings.Permalink()))
meta.Keywords = service.JoinSEOKeywords(board.Name, siteKeywords)
return meta
}
@@ -307,6 +403,12 @@ func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.S
meta.Keywords = service.JoinSEOKeywords("项目", siteKeywords)
}
+ if path == "/links" {
+ meta.Title = pageTitle("友情链接", siteName)
+ meta.Description = service.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax)
+ meta.Keywords = service.JoinSEOKeywords("友情链接", siteKeywords)
+ }
+
return meta
}
@@ -436,12 +538,13 @@ func pathWithQuery(c *gin.Context) string {
if path == "" {
path = "/"
}
+ permalink := service.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board
if q := c.Request.URL.RawQuery; q != "" {
- // 首页排序/搜索不作为 canonical;板块筛选保留
if path == "/" {
board := c.Query("board")
if board != "" {
- return service.QueryBoardHome(uint(parseUintOrZero(board)))
+ _ = permalink
+ return service.LegacyQueryBoardHome(uint(parseUintOrZero(board)))
}
return "/"
}
@@ -450,6 +553,15 @@ func pathWithQuery(c *gin.Context) string {
return path
}
+func preserveQueryExceptBoard(c *gin.Context) string {
+ vals := c.Request.URL.Query()
+ vals.Del("board")
+ if rest := vals.Encode(); rest != "" {
+ return "?" + rest
+ }
+ return ""
+}
+
func parseUintOrZero(s string) uint64 {
n, _ := strconv.ParseUint(s, 10, 64)
return n
diff --git a/handler/seo_bot.go b/handler/seo_bot.go
index 93183ee..c633136 100644
--- a/handler/seo_bot.go
+++ b/handler/seo_bot.go
@@ -87,6 +87,18 @@ func writeEscapedMeta(b *strings.Builder, attr, key, content string) {
b.WriteString("")
}
+func (h *Handlers) botBoardHTML(meta *embed_static.SPAPageMeta, board model.Board) string {
+ desc := strings.TrimSpace(board.Description)
+ if desc == "" {
+ desc = meta.Description
+ }
+ body := fmt.Sprintf(`%s
%s
`,
+ html.EscapeString(board.Name),
+ html.EscapeString(desc),
+ )
+ return renderBotHTML(meta, body)
+}
+
func (h *Handlers) botHomeHTML(meta *embed_static.SPAPageMeta, brand service.SiteBranding) string {
name := strings.TrimSpace(brand.Name)
if name == "" {
diff --git a/handler/special.go b/handler/special.go
new file mode 100644
index 0000000..db13520
--- /dev/null
+++ b/handler/special.go
@@ -0,0 +1,158 @@
+package handler
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "git.iioio.com/freefire/jiang13-forum/service"
+)
+
+// APIPages 已发布单页摘要列表
+func (h *Handlers) APIPages(c *gin.Context) {
+ pages, err := h.SitePage.ListPublished()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if pages == nil {
+ pages = []service.SitePageSummary{}
+ }
+ c.JSON(http.StatusOK, gin.H{"pages": pages})
+}
+
+// APIPageDetail 单页详情
+func (h *Handlers) APIPageDetail(c *gin.Context) {
+ slug := c.Param("slug")
+ allowDraft := h.isAdmin(c)
+ page, err := h.SitePage.GetBySlug(slug, allowDraft)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"page": page})
+}
+
+// APIAdminPages 管理端单页列表
+func (h *Handlers) APIAdminPages(c *gin.Context) {
+ pages, err := h.SitePage.ListAll()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if pages == nil {
+ pages = []model.SitePage{}
+ }
+ c.JSON(http.StatusOK, gin.H{"pages": pages})
+}
+
+// APIAdminCreatePage 创建单页
+func (h *Handlers) APIAdminCreatePage(c *gin.Context) {
+ var in service.SitePageInput
+ if err := c.ShouldBindJSON(&in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"})
+ return
+ }
+ page, err := h.SitePage.Create(in)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "单页已创建", "page": page})
+}
+
+// APIAdminUpdatePage 更新单页
+func (h *Handlers) APIAdminUpdatePage(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ var in service.SitePageInput
+ if err := c.ShouldBindJSON(&in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"})
+ return
+ }
+ if err := h.SitePage.Update(uint(id), in); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "单页已更新"})
+}
+
+// APIAdminDeletePage 删除单页
+func (h *Handlers) APIAdminDeletePage(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err := h.SitePage.Delete(uint(id)); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "单页已删除"})
+}
+
+// APIPollVote 投票
+func (h *Handlers) APIPollVote(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ var body struct {
+ OptionIDs []uint `json:"option_ids"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式无效"})
+ return
+ }
+ if err := service.VotePoll(uint(id), h.currentUserID(c), body.OptionIDs); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ poll, _ := service.GetPollView(uint(id), h.currentUserID(c))
+ c.JSON(http.StatusOK, gin.H{"message": "投票成功", "poll": poll})
+}
+
+// APIPollClose 结束投票
+func (h *Handlers) APIPollClose(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ post, err := h.Post.FindByID(uint(id))
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
+ return
+ }
+ if err := service.ClosePoll(uint(id), h.currentUserID(c), h.isAdmin(c), post.UserID); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ poll, _ := service.GetPollView(uint(id), h.currentUserID(c))
+ c.JSON(http.StatusOK, gin.H{"message": "投票已结束", "poll": poll})
+}
+
+// APIBountyAward 采纳悬赏
+func (h *Handlers) APIBountyAward(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ commentID, _ := strconv.ParseUint(c.PostForm("comment_id"), 10, 64)
+ if commentID == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请选择评论"})
+ return
+ }
+ if err := service.AwardBounty(uint(id), h.currentUserID(c), h.isAdmin(c), uint(commentID)); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "悬赏已发放"})
+}
+
+// APIBountyRefund 退回悬赏
+func (h *Handlers) APIBountyRefund(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err := service.RefundBounty(uint(id), h.currentUserID(c), h.isAdmin(c)); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "悬赏已退回"})
+}
+
+// APILotteryDraw 帖内抽奖开奖
+func (h *Handlers) APILotteryDraw(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ view, err := service.DrawPostLottery(uint(id), h.currentUserID(c), h.isAdmin(c))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "开奖完成", "lottery": view})
+}
diff --git a/model/db.go b/model/db.go
index 48f1ef4..94a1ad2 100644
--- a/model/db.go
+++ b/model/db.go
@@ -38,10 +38,11 @@ func InitDB(dbPath string) error {
&PostLike{}, &CommentLike{}, &PostFavorite{}, &PostRevision{}, &CommentRevision{}, &ForumSetting{},
&OAuthClient{}, &OAuthAuthCode{},
&GiteaRepo{},
- &PrivateMessage{}, &PostReport{},
+ &PrivateMessage{}, &PostReport{}, &FriendLinkApply{},
&Media{},
&PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{},
&BadgeDef{}, &UserBadge{},
+ &SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
diff --git a/model/models.go b/model/models.go
index 8970ad1..7fdd7c8 100644
--- a/model/models.go
+++ b/model/models.go
@@ -25,6 +25,22 @@ const (
const (
PostTypeNormal = "normal" // 普通讨论
PostTypeQuestion = "question" // 问答(未解决 / 已解决)
+ PostTypePoll = "poll" // 投票
+ PostTypeBounty = "bounty" // 悬赏
+ PostTypeLottery = "lottery" // 抽奖
+)
+
+// 悬赏状态
+const (
+ BountyStatusOpen = "open"
+ BountyStatusAwarded = "awarded"
+ BountyStatusRefunded = "refunded"
+)
+
+// 帖内抽奖状态
+const (
+ PostLotteryStatusOpen = "open"
+ PostLotteryStatusDrawn = "drawn"
)
// User 用户表
@@ -90,8 +106,13 @@ type Post struct {
Content string `gorm:"type:text;not null" json:"content"`
ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
Tags string `gorm:"size:256" json:"tags"`
- PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question
+ PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question|poll|bounty|lottery
QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义
+ BountyPoints int `gorm:"default:0" json:"bounty_points"` // 悬赏积分(仅 bounty)
+ BountyStatus string `gorm:"size:16;default:'';index" json:"bounty_status"` // open|awarded|refunded
+ BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论
+ LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery)
+ LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
@@ -236,6 +257,36 @@ const (
ReportReasonOther = "other"
)
+// 友链申请状态
+const (
+ FriendLinkApplyStatusPending = "pending"
+ FriendLinkApplyStatusApproved = "approved"
+ FriendLinkApplyStatusRejected = "rejected"
+)
+
+// FriendLinkApply 友情链接申请
+type FriendLinkApply struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"index;not null" json:"user_id"`
+ Name string `gorm:"size:32;not null" json:"name"`
+ URL string `gorm:"size:512;not null" json:"url"`
+ Description string `gorm:"size:200;default:''" json:"description,omitempty"`
+ Logo string `gorm:"size:512;default:''" json:"logo"`
+ ReciprocalPageURL string `gorm:"size:512;default:''" json:"reciprocal_page_url"`
+ LinkOnHomepage bool `gorm:"default:true" json:"link_on_homepage"`
+ ReciprocalVerified bool `gorm:"default:false" json:"reciprocal_verified"`
+ ReciprocalCheckNote string `gorm:"size:256;default:''" json:"reciprocal_check_note,omitempty"`
+ ReciprocalCheckedAt *time.Time `json:"reciprocal_checked_at,omitempty"`
+ Status string `gorm:"size:16;default:pending;index" json:"status"`
+ ReviewNote string `gorm:"size:256;default:''" json:"review_note,omitempty"`
+ ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
+}
+
// PostReport 帖子/评论举报(CommentID 有值时为评论举报)
type PostReport struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -277,6 +328,9 @@ const (
PointReasonUnlockSpend = "unlock_spend"
PointReasonCreatorIncome = "creator_income"
PointReasonAdminAdjust = "admin_adjust"
+ PointReasonBountyEscrow = "bounty_escrow"
+ PointReasonBountyAward = "bounty_award"
+ PointReasonBountyRefund = "bounty_refund"
)
// PointLedger 积分流水
@@ -321,6 +375,58 @@ type PostContentUnlock struct {
CreatedAt time.Time `json:"created_at"`
}
+// SitePage 自定义单页(关于我们、版规等)
+type SitePage struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Title string `gorm:"size:128;not null" json:"title"`
+ Slug string `gorm:"uniqueIndex;size:64;not null" json:"slug"`
+ Content string `gorm:"type:text;not null" json:"content"`
+ Published bool `gorm:"default:false;index" json:"published"`
+ SortOrder int `gorm:"default:0" json:"sort_order"`
+ ShowInFooter bool `gorm:"default:false" json:"show_in_footer"`
+ ShowInNav bool `gorm:"default:false" json:"show_in_nav"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
+
+// Poll 投票帖配置
+type Poll struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ PostID uint `gorm:"uniqueIndex;not null" json:"post_id"`
+ Multi bool `gorm:"default:false" json:"multi"`
+ MaxChoices int `gorm:"default:1" json:"max_choices"`
+ Closed bool `gorm:"default:false;index" json:"closed"`
+ EndsAt *time.Time `gorm:"index" json:"ends_at,omitempty"`
+}
+
+// PollOption 投票选项
+type PollOption struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ PostID uint `gorm:"index;not null" json:"post_id"`
+ Text string `gorm:"size:64;not null" json:"text"`
+ SortOrder int `gorm:"default:0" json:"sort_order"`
+ VoteCount int `gorm:"default:0" json:"vote_count"`
+}
+
+// PollVote 投票记录
+type PollVote struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ PostID uint `gorm:"uniqueIndex:idx_poll_vote_multi;index;not null" json:"post_id"`
+ OptionID uint `gorm:"uniqueIndex:idx_poll_vote_multi;index;not null" json:"option_id"`
+ UserID uint `gorm:"uniqueIndex:idx_poll_vote_multi;index;not null" json:"user_id"`
+}
+
+// PostLotteryWinner 帖内抽奖中奖记录
+type PostLotteryWinner struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ PostID uint `gorm:"index;not null" json:"post_id"`
+ UserID uint `gorm:"index;not null" json:"user_id"`
+ CommentID uint `gorm:"default:0" json:"comment_id"`
+ CreatedAt time.Time `json:"created_at"`
+ User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
+}
+
// 徽章类型
const (
BadgeKindAuto = "auto"
diff --git a/router/router.go b/router/router.go
index 0fb517c..976ecaf 100644
--- a/router/router.go
+++ b/router/router.go
@@ -7,12 +7,12 @@ import (
"os"
"path/filepath"
- "github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/embed_static"
"git.iioio.com/freefire/jiang13-forum/handler"
"git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/service"
+ "github.com/gin-gonic/gin"
)
func Setup(cfg *config.Config) (*gin.Engine, error) {
@@ -61,6 +61,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
mailSvc := service.NewMailService(settingsSvc)
emailCodeSvc := service.NewEmailCodeService(mailSvc)
notifySvc := service.NewNotifyService(messageSvc, mailSvc, settingsSvc)
+ friendLinkApplySvc := service.NewFriendLinkApplyService(settingsSvc, messageSvc)
oidcSvc, err := service.NewOIDCService(cfg, settingsSvc)
if err != nil {
return nil, err
@@ -91,6 +92,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
OIDC: oidcSvc, Gitea: giteaSvc,
Points: service.NewPointsService(), Badge: service.NewBadgeService(),
+ SitePage: service.NewSitePageService(filter),
+ FriendLinkApply: friendLinkApplySvc,
}
authMW := middleware.NewAuthMiddleware(authSvc)
@@ -123,6 +126,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/stats", h.APIStats)
pubAPI.GET("/forum-limits", h.APIForumLimits)
pubAPI.GET("/site-branding", h.APISiteBranding)
+ pubAPI.GET("/pages", h.APIPages)
+ pubAPI.GET("/pages/:slug", h.APIPageDetail)
pubAPI.GET("/captcha", h.APICaptcha)
pubAPI.GET("/register/config", h.APIRegisterConfig)
pubAPI.POST("/register/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
@@ -162,6 +167,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/posts/:id/like", h.APIToggleLike)
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
+ api.POST("/posts/:id/poll/vote", h.APIPollVote)
+ api.POST("/posts/:id/poll/close", h.APIPollClose)
+ api.POST("/posts/:id/bounty/award", h.APIBountyAward)
+ api.POST("/posts/:id/bounty/refund", h.APIBountyRefund)
+ api.POST("/posts/:id/lottery/draw", h.APILotteryDraw)
api.POST("/posts/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
api.GET("/messages/unread-count", h.APIMessageUnreadCount)
api.GET("/messages/notifications", h.APIMessageNotifications)
@@ -176,10 +186,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.DELETE("/comments/:id", h.APIDeleteComment)
api.PUT("/comments/:id", h.APIUpdateComment)
api.GET("/me/points", h.APIMePoints)
+ api.GET("/me/check-in", h.APIMeCheckInGet)
api.POST("/me/check-in", h.APIMeCheckIn)
api.GET("/me/lottery", h.APIMeLotteryGet)
api.POST("/me/lottery", h.APIMeLotteryDraw)
api.POST("/posts/:id/unlock", middleware.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock)
+ api.POST("/friend-links/apply", middleware.RateLimitMiddleware(limiter, "friend_link"), h.APIApplyFriendLink)
+ api.POST("/friend-links/logo", middleware.RateLimitMiddleware(limiter, "post"), h.APIUploadFriendLinkLogo)
+ api.GET("/friend-links/my-applies", h.APIMyFriendLinkApplies)
+ api.PUT("/friend-links/applies/:id", middleware.RateLimitMiddleware(limiter, "friend_link"), h.APIUpdateFriendLinkApply)
+ api.DELETE("/friend-links/applies/:id", h.APICancelFriendLinkApply)
}
// 管理员 API(React SPA 后台统一使用 JSON)
@@ -206,6 +222,15 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.POST("/boards", h.APIAdminCreateBoard)
adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard)
adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard)
+ adminAPI.GET("/pages", h.APIAdminPages)
+ adminAPI.POST("/pages", h.APIAdminCreatePage)
+ adminAPI.PUT("/pages/:id", h.APIAdminUpdatePage)
+ adminAPI.DELETE("/pages/:id", h.APIAdminDeletePage)
+ adminAPI.GET("/friend-link-applies", h.APIAdminFriendLinkApplies)
+ adminAPI.PUT("/friend-link-settings", h.APIAdminUpdateFriendLinkSettings)
+ adminAPI.POST("/friend-link-applies/:id/approve", h.APIAdminApproveFriendLinkApply)
+ adminAPI.POST("/friend-link-applies/:id/reject", h.APIAdminRejectFriendLinkApply)
+ adminAPI.POST("/friend-link-applies/:id/recheck", h.APIAdminRecheckFriendLinkApply)
adminAPI.GET("/posts", h.APIAdminPosts)
adminAPI.GET("/posts/trash", h.APIAdminTrashPosts)
adminAPI.POST("/posts/:id/pin", h.APIAdminPinPost)
@@ -254,7 +279,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
{
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
- for _, page := range []string{"dashboard", "boards", "posts", "comments", "reports", "users", "badges", "media", "settings"} {
+ for _, page := range []string{"dashboard", "boards", "pages", "links", "posts", "comments", "reports", "users", "badges", "media", "settings"} {
adminAuth.GET("/"+page, embed_static.ServeSPANoIndex)
}
}
diff --git a/service/aside_widgets_test.go b/service/aside_widgets_test.go
new file mode 100644
index 0000000..613a22f
--- /dev/null
+++ b/service/aside_widgets_test.go
@@ -0,0 +1,36 @@
+package service
+
+import "testing"
+
+func TestNormalizeAsideWidgetsPreservesOrder(t *testing.T) {
+ in := []AsideWidget{
+ {ID: AsideWidgetFriendLinks, Enabled: true},
+ {ID: AsideWidgetTagCloud, Enabled: true},
+ {ID: AsideWidgetRecentComments, Enabled: false},
+ }
+ out := NormalizeAsideWidgets(in)
+ if len(out) != 3 {
+ t.Fatalf("want 3 widgets, got %d", len(out))
+ }
+ want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments}
+ for i, id := range want {
+ if out[i].ID != id {
+ t.Fatalf("index %d: want %s, got %s", i, id, out[i].ID)
+ }
+ }
+ if !out[0].Enabled || !out[1].Enabled || out[2].Enabled {
+ t.Fatalf("enabled flags mismatch: %+v", out)
+ }
+}
+
+func TestAsideBoolsFromWidgets(t *testing.T) {
+ widgets := []AsideWidget{
+ {ID: AsideWidgetRecentComments, Enabled: true},
+ {ID: AsideWidgetFriendLinks, Enabled: false},
+ {ID: AsideWidgetTagCloud, Enabled: true},
+ }
+ bools := asideBoolsFromWidgets(widgets)
+ if !bools.tagCloud || !bools.recentComments || bools.friendLinks {
+ t.Fatalf("unexpected bools: %+v", bools)
+ }
+}
diff --git a/service/bounty.go b/service/bounty.go
new file mode 100644
index 0000000..0a49fb7
--- /dev/null
+++ b/service/bounty.go
@@ -0,0 +1,150 @@
+package service
+
+import (
+ "errors"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "gorm.io/gorm"
+)
+
+var (
+ ErrBountyNotOpen = errors.New("悬赏已结束或已退回")
+ ErrBountySelfAward = errors.New("不能采纳自己的回复")
+ ErrBountyInvalidPoint = errors.New("悬赏积分至少为 1")
+ ErrBountyRefundBlocked = errors.New("已有用户回复,无法自行取消悬赏,请采纳优质回复或联系管理员")
+)
+
+const bountyRefundBlockReason = "已有用户回复,无法自行取消悬赏,请采纳优质回复或联系管理员"
+
+// CountEligibleBountyReplies 统计他人已发布的有效回复数(不含楼主)
+func CountEligibleBountyReplies(db *gorm.DB, postID, authorID uint) (int64, error) {
+ if db == nil {
+ db = model.DB
+ }
+ var n int64
+ err := db.Model(&model.Comment{}).
+ Where("post_id = ? AND status = ? AND user_id != ?", postID, model.ContentStatusPublished, authorID).
+ Count(&n).Error
+ return n, err
+}
+
+// CanRefundBounty 当前查看者是否可取消悬赏(管理员始终可强制取消)
+func CanRefundBounty(post *model.Post, viewerIsAdmin bool) (bool, string) {
+ if post == nil || post.PostType != model.PostTypeBounty {
+ return false, ""
+ }
+ if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 {
+ return false, ""
+ }
+ if viewerIsAdmin {
+ return true, ""
+ }
+ n, err := CountEligibleBountyReplies(model.DB, post.ID, post.UserID)
+ if err != nil {
+ return false, ""
+ }
+ if n > 0 {
+ return false, bountyRefundBlockReason
+ }
+ return true, ""
+}
+
+// EscrowBounty 发帖时托管悬赏积分
+func EscrowBounty(tx *gorm.DB, userID, postID uint, points int) error {
+ if points < 1 {
+ return ErrBountyInvalidPoint
+ }
+ _, err := AdjustPointsTx(tx, userID, -points, model.PointReasonBountyEscrow, "post", postID, "发布悬赏帖")
+ return err
+}
+
+// AwardBounty 采纳评论并发放悬赏
+func AwardBounty(postID, operatorID uint, isAdmin bool, commentID uint) error {
+ var post model.Post
+ if err := model.DB.First(&post, postID).Error; err != nil {
+ return ErrPostNotFound
+ }
+ if post.PostType != model.PostTypeBounty {
+ return errors.New("非悬赏帖")
+ }
+ if !isAdmin && post.UserID != operatorID {
+ return ErrPermissionDenied
+ }
+ if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 {
+ return ErrBountyNotOpen
+ }
+ var comment model.Comment
+ if err := model.DB.First(&comment, commentID).Error; err != nil {
+ return errors.New("评论不存在")
+ }
+ if comment.PostID != postID || comment.Status != model.ContentStatusPublished {
+ return errors.New("评论无效")
+ }
+ if comment.UserID == post.UserID {
+ return ErrBountySelfAward
+ }
+ points := post.BountyPoints
+ return model.DB.Transaction(func(tx *gorm.DB) error {
+ if _, err := AdjustPointsTx(tx, comment.UserID, points, model.PointReasonBountyAward, "post", postID, "悬赏采纳"); err != nil {
+ return err
+ }
+ return tx.Model(&post).Updates(map[string]interface{}{
+ "bounty_status": model.BountyStatusAwarded,
+ "bounty_comment_id": commentID,
+ }).Error
+ })
+}
+
+// RefundBounty 取消悬赏并退回积分
+func RefundBounty(postID, operatorID uint, isAdmin bool) error {
+ var post model.Post
+ if err := model.DB.First(&post, postID).Error; err != nil {
+ return ErrPostNotFound
+ }
+ if post.PostType != model.PostTypeBounty {
+ return errors.New("非悬赏帖")
+ }
+ if !isAdmin && post.UserID != operatorID {
+ return ErrPermissionDenied
+ }
+ if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 {
+ return ErrBountyNotOpen
+ }
+ if !isAdmin {
+ n, err := CountEligibleBountyReplies(model.DB, post.ID, post.UserID)
+ if err != nil {
+ return err
+ }
+ if n > 0 {
+ return ErrBountyRefundBlocked
+ }
+ }
+ points := post.BountyPoints
+ return model.DB.Transaction(func(tx *gorm.DB) error {
+ if _, err := AdjustPointsTx(tx, post.UserID, points, model.PointReasonBountyRefund, "post", postID, "悬赏退回"); err != nil {
+ return err
+ }
+ return tx.Model(&post).Updates(map[string]interface{}{
+ "bounty_status": model.BountyStatusRefunded,
+ "bounty_points": 0,
+ }).Error
+ })
+}
+
+// RefundBountyIfOpen 删帖时自动退回未采纳悬赏
+func RefundBountyIfOpen(tx *gorm.DB, post *model.Post) error {
+ if post == nil || post.PostType != model.PostTypeBounty {
+ return nil
+ }
+ if post.BountyStatus != model.BountyStatusOpen || post.BountyPoints < 1 {
+ return nil
+ }
+ points := post.BountyPoints
+ if _, err := AdjustPointsTx(tx, post.UserID, points, model.PointReasonBountyRefund, "post", post.ID, "删帖退回悬赏"); err != nil {
+ return err
+ }
+ return tx.Model(post).Updates(map[string]interface{}{
+ "bounty_status": model.BountyStatusRefunded,
+ "bounty_points": 0,
+ }).Error
+}
diff --git a/service/bounty_test.go b/service/bounty_test.go
new file mode 100644
index 0000000..b5fe17f
--- /dev/null
+++ b/service/bounty_test.go
@@ -0,0 +1,175 @@
+package service
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/glebarez/sqlite"
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "gorm.io/gorm"
+)
+
+func setupBountyTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(&model.User{}, &model.Post{}, &model.Comment{}, &model.PointLedger{}); err != nil {
+ t.Fatal(err)
+ }
+ prev := model.DB
+ model.DB = db
+ t.Cleanup(func() { model.DB = prev })
+ return db
+}
+
+func seedBountyPost(t *testing.T, db *gorm.DB, authorID uint, points int) model.Post {
+ t.Helper()
+ post := model.Post{
+ UserID: authorID,
+ BoardID: 1,
+ Title: "悬赏测试",
+ Content: "内容",
+ PostType: model.PostTypeBounty,
+ BountyPoints: points,
+ BountyStatus: model.BountyStatusOpen,
+ Status: model.ContentStatusPublished,
+ }
+ if err := db.Create(&post).Error; err != nil {
+ t.Fatal(err)
+ }
+ return post
+}
+
+func seedUser(t *testing.T, db *gorm.DB, id uint, points int) {
+ t.Helper()
+ u := model.User{
+ ID: id,
+ Username: "user" + string(rune('0'+id)),
+ Password: "hash",
+ Nickname: "测试",
+ Points: points,
+ }
+ if err := db.Create(&u).Error; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func seedComment(t *testing.T, db *gorm.DB, postID, userID uint, floor int, status string) {
+ t.Helper()
+ c := model.Comment{
+ PostID: postID,
+ UserID: userID,
+ Floor: floor,
+ Content: "回复",
+ Status: status,
+ }
+ if err := db.Create(&c).Error; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCountEligibleBountyReplies(t *testing.T) {
+ db := setupBountyTestDB(t)
+ post := seedBountyPost(t, db, 1, 10)
+
+ n, err := CountEligibleBountyReplies(db, post.ID, 1)
+ if err != nil || n != 0 {
+ t.Fatalf("无回复时期望 0,得到 %d err=%v", n, err)
+ }
+
+ seedComment(t, db, post.ID, 1, 1, model.ContentStatusPublished)
+ n, err = CountEligibleBountyReplies(db, post.ID, 1)
+ if err != nil || n != 0 {
+ t.Fatalf("楼主自己的回复不应计入,得到 %d", n)
+ }
+
+ seedComment(t, db, post.ID, 2, 2, model.ContentStatusPublished)
+ n, err = CountEligibleBountyReplies(db, post.ID, 1)
+ if err != nil || n != 1 {
+ t.Fatalf("他人 published 回复期望 1,得到 %d", n)
+ }
+
+ seedComment(t, db, post.ID, 3, 3, model.ContentStatusPending)
+ n, err = CountEligibleBountyReplies(db, post.ID, 1)
+ if err != nil || n != 1 {
+ t.Fatalf("pending 回复不应增加计数,得到 %d", n)
+ }
+
+ seedComment(t, db, post.ID, 0, 4, model.ContentStatusPublished)
+ n, err = CountEligibleBountyReplies(db, post.ID, 1)
+ if err != nil || n != 2 {
+ t.Fatalf("游客回复应计入,得到 %d", n)
+ }
+}
+
+func TestCanRefundBounty(t *testing.T) {
+ db := setupBountyTestDB(t)
+ post := seedBountyPost(t, db, 1, 5)
+
+ can, reason := CanRefundBounty(&post, false)
+ if !can || reason != "" {
+ t.Fatalf("无回复时楼主应可退,can=%v reason=%q", can, reason)
+ }
+
+ seedComment(t, db, post.ID, 2, 1, model.ContentStatusPublished)
+ can, reason = CanRefundBounty(&post, false)
+ if can || reason != bountyRefundBlockReason {
+ t.Fatalf("有他人回复时楼主不可退,can=%v reason=%q", can, reason)
+ }
+
+ can, reason = CanRefundBounty(&post, true)
+ if !can || reason != "" {
+ t.Fatalf("管理员应可强制退,can=%v reason=%q", can, reason)
+ }
+}
+
+func TestRefundBountyBlockedForAuthorWithReplies(t *testing.T) {
+ db := setupBountyTestDB(t)
+ seedUser(t, db, 1, 0)
+ seedUser(t, db, 2, 0)
+ post := seedBountyPost(t, db, 1, 8)
+ seedComment(t, db, post.ID, 2, 1, model.ContentStatusPublished)
+
+ err := RefundBounty(post.ID, 1, false)
+ if !errors.Is(err, ErrBountyRefundBlocked) {
+ t.Fatalf("楼主有他人回复时应拒绝退回,err=%v", err)
+ }
+}
+
+func TestRefundBountyAllowedWithoutReplies(t *testing.T) {
+ db := setupBountyTestDB(t)
+ seedUser(t, db, 1, 0)
+ post := seedBountyPost(t, db, 1, 6)
+
+ if err := RefundBounty(post.ID, 1, false); err != nil {
+ t.Fatalf("无回复时楼主应可退回,err=%v", err)
+ }
+ var updated model.Post
+ if err := db.First(&updated, post.ID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if updated.BountyStatus != model.BountyStatusRefunded || updated.BountyPoints != 0 {
+ t.Fatalf("状态应为 refunded 且积分为 0,得到 status=%s points=%d", updated.BountyStatus, updated.BountyPoints)
+ }
+ var author model.User
+ if err := db.First(&author, 1).Error; err != nil {
+ t.Fatal(err)
+ }
+ if author.Points != 6 {
+ t.Fatalf("楼主应收回 6 积分,余额=%d", author.Points)
+ }
+}
+
+func TestRefundBountyAdminBypassWithReplies(t *testing.T) {
+ db := setupBountyTestDB(t)
+ seedUser(t, db, 1, 0)
+ seedUser(t, db, 2, 0)
+ post := seedBountyPost(t, db, 1, 4)
+ seedComment(t, db, post.ID, 2, 1, model.ContentStatusPublished)
+
+ if err := RefundBounty(post.ID, 99, true); err != nil {
+ t.Fatalf("管理员应可强制退回,err=%v", err)
+ }
+}
diff --git a/service/friend_link.go b/service/friend_link.go
new file mode 100644
index 0000000..1a765f2
--- /dev/null
+++ b/service/friend_link.go
@@ -0,0 +1,473 @@
+package service
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "gorm.io/gorm"
+)
+
+var (
+ ErrFriendLinkApplyPending = errors.New("该 URL 已有待审核申请")
+ ErrFriendLinkApplyExists = errors.New("该 URL 已在友情链接中")
+ ErrFriendLinkApplyNotFound = errors.New("申请不存在")
+ ErrFriendLinkApplyHandled = errors.New("申请已处理")
+ ErrFriendLinkApplyFull = errors.New("友情链接已达上限(20 条)")
+)
+
+const (
+ maxFriendLinkApplyDesc = 200
+)
+
+type FriendLinkApplyListQuery struct {
+ Page int
+ Size int
+ Status string
+}
+
+type FriendLinkApplyInput struct {
+ UserID uint
+ Name string
+ URL string
+ Logo string
+ LinkOnHomepage bool
+ ReciprocalPageURL string
+ OurSiteURL string
+}
+
+type FriendLinkApplyCreateResult struct {
+ Apply *model.FriendLinkApply
+}
+
+type FriendLinkApplyService struct {
+ settings *ForumSettingsService
+ messages *MessageService
+}
+
+func NewFriendLinkApplyService(settings *ForumSettingsService, messages *MessageService) *FriendLinkApplyService {
+ return &FriendLinkApplyService{settings: settings, messages: messages}
+}
+
+func normalizeFriendLinkApplyURL(raw string) (string, error) {
+ href := strings.TrimSpace(raw)
+ if href == "" {
+ return "", errors.New("请填写 URL")
+ }
+ u, err := url.Parse(href)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return "", errors.New("URL 格式无效")
+ }
+ scheme := strings.ToLower(u.Scheme)
+ if scheme != "http" && scheme != "https" {
+ return "", errors.New("URL 需为 http 或 https")
+ }
+ return href, nil
+}
+
+func friendLinkURLKey(href string) string {
+ u, err := url.Parse(strings.TrimSpace(href))
+ if err != nil {
+ return strings.ToLower(strings.TrimSpace(href))
+ }
+ u.Scheme = strings.ToLower(u.Scheme)
+ u.Host = strings.ToLower(u.Host)
+ u.Path = strings.TrimSuffix(u.Path, "/")
+ return u.String()
+}
+
+func (s *FriendLinkApplyService) urlInFriendLinks(href string) bool {
+ key := friendLinkURLKey(href)
+ brand := s.settings.SiteBranding()
+ for _, l := range brand.FriendLinks {
+ if friendLinkURLKey(l.URL) == key {
+ return true
+ }
+ }
+ return false
+}
+
+// Create 提交友链申请
+func (s *FriendLinkApplyService) Create(in FriendLinkApplyInput) (*FriendLinkApplyCreateResult, error) {
+ name, href, logo, reciprocal, err := s.prepareApplyFields(in, "")
+ if err != nil {
+ return nil, err
+ }
+
+ dup, err := s.hasPendingApplyForURL(in.UserID, 0, href)
+ if err != nil {
+ return nil, err
+ }
+ if dup {
+ return nil, ErrFriendLinkApplyPending
+ }
+
+ apply := &model.FriendLinkApply{
+ UserID: in.UserID,
+ Name: name,
+ URL: href,
+ Logo: logo,
+ ReciprocalPageURL: reciprocal,
+ LinkOnHomepage: in.LinkOnHomepage,
+ Status: model.FriendLinkApplyStatusPending,
+ }
+ if err := model.DB.Create(apply).Error; err != nil {
+ return nil, err
+ }
+ s.startReciprocalCheck(apply.ID, reciprocal, in.OurSiteURL)
+ _ = model.DB.Preload("User").First(apply, apply.ID).Error
+ return &FriendLinkApplyCreateResult{Apply: apply}, nil
+}
+
+// PendingCount 待审数量
+func (s *FriendLinkApplyService) PendingCount() (int64, error) {
+ var n int64
+ err := model.DB.Model(&model.FriendLinkApply{}).
+ Where("status = ?", model.FriendLinkApplyStatusPending).
+ Count(&n).Error
+ return n, err
+}
+
+// ListAdmin 管理员列表
+func (s *FriendLinkApplyService) ListAdmin(q FriendLinkApplyListQuery) ([]model.FriendLinkApply, int64, error) {
+ if q.Page < 1 {
+ q.Page = 1
+ }
+ if q.Size < 1 || q.Size > 50 {
+ q.Size = 20
+ }
+ db := model.DB.Model(&model.FriendLinkApply{})
+ status := strings.TrimSpace(q.Status)
+ if status != "" && status != "all" {
+ db = db.Where("status = ?", status)
+ }
+ var total int64
+ if err := db.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ var list []model.FriendLinkApply
+ err := db.Preload("User").
+ Order("CASE WHEN status = 'pending' THEN 0 ELSE 1 END, id DESC").
+ Offset((q.Page - 1) * q.Size).
+ Limit(q.Size).
+ Find(&list).Error
+ if err != nil {
+ return nil, 0, err
+ }
+ return list, total, nil
+}
+
+func (s *FriendLinkApplyService) getPending(id uint) (*model.FriendLinkApply, error) {
+ var apply model.FriendLinkApply
+ if err := model.DB.Preload("User").First(&apply, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrFriendLinkApplyNotFound
+ }
+ return nil, err
+ }
+ if apply.Status != model.FriendLinkApplyStatusPending {
+ return nil, ErrFriendLinkApplyHandled
+ }
+ return &apply, nil
+}
+
+// Approve 通过申请并写入友链
+func (s *FriendLinkApplyService) Approve(id uint) (*model.FriendLinkApply, error) {
+ apply, err := s.getPending(id)
+ if err != nil {
+ return nil, err
+ }
+ if s.urlInFriendLinks(apply.URL) {
+ return nil, ErrFriendLinkApplyExists
+ }
+
+ brand := s.settings.SiteBranding()
+ if len(brand.FriendLinks) >= maxFriendLinks {
+ return nil, ErrFriendLinkApplyFull
+ }
+ nextLinks := append(brand.FriendLinks, FriendLink{
+ Name: apply.Name,
+ URL: apply.URL,
+ Logo: normalizeFriendLinkLogoOptional(apply.Logo),
+ })
+ if err := s.settings.UpdateSiteBranding(SiteBranding{
+ Name: brand.Name,
+ Slogan: brand.Slogan,
+ Description: brand.Description,
+ Keywords: brand.Keywords,
+ LogoMark: brand.LogoMark,
+ Logo: brand.Logo,
+ Favicon: brand.Favicon,
+ OGImage: brand.OGImage,
+ ICPBeian: brand.ICPBeian,
+ ICPBeianURL: brand.ICPBeianURL,
+ FriendLinks: nextLinks,
+ }); err != nil {
+ return nil, err
+ }
+
+ now := time.Now()
+ if err := model.DB.Model(apply).Updates(map[string]interface{}{
+ "status": model.FriendLinkApplyStatusApproved,
+ "reviewed_at": now,
+ }).Error; err != nil {
+ return nil, err
+ }
+ apply.Status = model.FriendLinkApplyStatusApproved
+ apply.ReviewedAt = &now
+
+ if s.messages != nil && apply.UserID > 0 {
+ subject := "友情链接申请已通过"
+ content := fmt.Sprintf(
+ "你申请的友情链接「%s」(%s)已通过审核,现已展示在友情链接页面。\n\n如有疑问,可回复本私信联系管理员。",
+ apply.Name, apply.URL,
+ )
+ _, _ = s.messages.SendSystem(apply.UserID, subject, content, model.MessageKindSystem, nil, nil)
+ }
+ return apply, nil
+}
+
+// Reject 拒绝申请
+func (s *FriendLinkApplyService) Reject(id uint, note string) (*model.FriendLinkApply, error) {
+ apply, err := s.getPending(id)
+ if err != nil {
+ return nil, err
+ }
+ note = strings.TrimSpace(note)
+ now := time.Now()
+ if err := model.DB.Model(apply).Updates(map[string]interface{}{
+ "status": model.FriendLinkApplyStatusRejected,
+ "review_note": note,
+ "reviewed_at": now,
+ }).Error; err != nil {
+ return nil, err
+ }
+ apply.Status = model.FriendLinkApplyStatusRejected
+ apply.ReviewNote = note
+ apply.ReviewedAt = &now
+
+ if s.messages != nil && apply.UserID > 0 {
+ subject := "友情链接申请未通过"
+ reason := note
+ if reason == "" {
+ reason = "未说明具体原因"
+ }
+ content := fmt.Sprintf(
+ "你申请的友情链接「%s」(%s)未通过审核。\n\n原因:\n%s\n\n如有疑问,可回复本私信联系管理员。",
+ apply.Name, apply.URL, reason,
+ )
+ _, _ = s.messages.SendSystem(apply.UserID, subject, content, model.MessageKindReject, nil, nil)
+ }
+ return apply, nil
+}
+
+func (s *FriendLinkApplyService) prepareApplyFields(in FriendLinkApplyInput, allowPublishedURL string) (name, href, logo, reciprocal string, err error) {
+ name = strings.TrimSpace(in.Name)
+ href, err = normalizeFriendLinkApplyURL(in.URL)
+ if err != nil {
+ return
+ }
+ logo, err = normalizeFriendLinkLogo(in.Logo)
+ if err != nil {
+ return
+ }
+ if name == "" {
+ err = errors.New("请填写站点名称")
+ return
+ }
+ if utf8.RuneCountInString(name) > maxFriendLinkName {
+ err = fmt.Errorf("站点名称最多 %d 字", maxFriendLinkName)
+ return
+ }
+ if s.urlInFriendLinks(href) && friendLinkURLKey(href) != friendLinkURLKey(allowPublishedURL) {
+ err = ErrFriendLinkApplyExists
+ return
+ }
+
+ reciprocal = strings.TrimSpace(in.ReciprocalPageURL)
+ if in.LinkOnHomepage {
+ reciprocal = href
+ } else {
+ reciprocal, err = normalizeFriendLinkApplyURL(reciprocal)
+ if err != nil {
+ err = errors.New("请填写添加本站链接的页面地址")
+ return
+ }
+ }
+ return
+}
+
+func (s *FriendLinkApplyService) hasPendingApplyForURL(userID, excludeID uint, href string) (bool, error) {
+ db := model.DB.Model(&model.FriendLinkApply{}).
+ Where("user_id = ? AND status = ? AND url = ?", userID, model.FriendLinkApplyStatusPending, href)
+ if excludeID > 0 {
+ db = db.Where("id <> ?", excludeID)
+ }
+ var pending int64
+ if err := db.Count(&pending).Error; err != nil {
+ return false, err
+ }
+ return pending > 0, nil
+}
+
+func (s *FriendLinkApplyService) removePublishedFriendLink(href string) error {
+ key := friendLinkURLKey(href)
+ brand := s.settings.SiteBranding()
+ next := make([]FriendLink, 0, len(brand.FriendLinks))
+ removed := false
+ for _, l := range brand.FriendLinks {
+ if friendLinkURLKey(l.URL) == key {
+ removed = true
+ continue
+ }
+ next = append(next, l)
+ }
+ if !removed {
+ return nil
+ }
+ return s.settings.UpdateSiteBranding(SiteBranding{
+ Name: brand.Name,
+ Slogan: brand.Slogan,
+ Description: brand.Description,
+ Keywords: brand.Keywords,
+ LogoMark: brand.LogoMark,
+ Logo: brand.Logo,
+ Favicon: brand.Favicon,
+ OGImage: brand.OGImage,
+ ICPBeian: brand.ICPBeian,
+ ICPBeianURL: brand.ICPBeianURL,
+ FriendLinks: next,
+ })
+}
+
+// Update 修改并重新提交友链申请(待审 / 已拒绝 / 已通过)
+func (s *FriendLinkApplyService) Update(userID, id uint, in FriendLinkApplyInput) (*FriendLinkApplyCreateResult, error) {
+ var apply model.FriendLinkApply
+ if err := model.DB.First(&apply, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrFriendLinkApplyNotFound
+ }
+ return nil, err
+ }
+ if apply.UserID != userID {
+ return nil, errors.New("无权操作该申请")
+ }
+ if apply.Status != model.FriendLinkApplyStatusPending &&
+ apply.Status != model.FriendLinkApplyStatusRejected &&
+ apply.Status != model.FriendLinkApplyStatusApproved {
+ return nil, errors.New("该申请不可修改")
+ }
+
+ wasApproved := apply.Status == model.FriendLinkApplyStatusApproved
+ allowPublishedURL := ""
+ if wasApproved {
+ allowPublishedURL = apply.URL
+ }
+
+ name, href, logo, reciprocal, err := s.prepareApplyFields(in, allowPublishedURL)
+ if err != nil {
+ return nil, err
+ }
+ dup, err := s.hasPendingApplyForURL(userID, id, href)
+ if err != nil {
+ return nil, err
+ }
+ if dup {
+ return nil, ErrFriendLinkApplyPending
+ }
+
+ if wasApproved {
+ if err := s.removePublishedFriendLink(apply.URL); err != nil {
+ return nil, err
+ }
+ }
+
+ updates := map[string]interface{}{
+ "name": name,
+ "url": href,
+ "logo": logo,
+ "reciprocal_page_url": reciprocal,
+ "link_on_homepage": in.LinkOnHomepage,
+ "reciprocal_verified": false,
+ "reciprocal_check_note": "",
+ "reciprocal_checked_at": nil,
+ "status": model.FriendLinkApplyStatusPending,
+ "review_note": "",
+ "reviewed_at": nil,
+ }
+ if err := model.DB.Model(&apply).Updates(updates).Error; err != nil {
+ return nil, err
+ }
+ s.startReciprocalCheck(apply.ID, reciprocal, in.OurSiteURL)
+ _ = model.DB.Preload("User").First(&apply, apply.ID).Error
+ return &FriendLinkApplyCreateResult{Apply: &apply}, nil
+}
+
+// RecheckReciprocal 管理员触发重新检测回链
+func (s *FriendLinkApplyService) RecheckReciprocal(id uint, ourSiteURL string) (*model.FriendLinkApply, error) {
+ if !s.settings.FriendLinkReciprocalCheckEnabled() {
+ return nil, errors.New("回链检测已关闭")
+ }
+ var apply model.FriendLinkApply
+ if err := model.DB.Preload("User").First(&apply, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrFriendLinkApplyNotFound
+ }
+ return nil, err
+ }
+ if strings.TrimSpace(apply.ReciprocalPageURL) == "" {
+ return nil, errors.New("该申请未填写回链页")
+ }
+ ResetReciprocalCheckState(apply.ID)
+ EnqueueReciprocalCheck(apply.ID, apply.ReciprocalPageURL, ourSiteURL)
+ apply.ReciprocalVerified = false
+ apply.ReciprocalCheckNote = ""
+ apply.ReciprocalCheckedAt = nil
+ return &apply, nil
+}
+
+// startReciprocalCheck 按开关启动回链检测;关闭时标记为已结束,避免前台一直显示「检测中」
+func (s *FriendLinkApplyService) startReciprocalCheck(applyID uint, pageURL, ourSiteURL string) {
+ if s.settings.FriendLinkReciprocalCheckEnabled() {
+ EnqueueReciprocalCheck(applyID, pageURL, ourSiteURL)
+ return
+ }
+ now := time.Now()
+ _ = model.DB.Model(&model.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{
+ "reciprocal_verified": false,
+ "reciprocal_check_note": "",
+ "reciprocal_checked_at": now,
+ }).Error
+}
+
+// ListMine 当前用户的友链申请
+func (s *FriendLinkApplyService) ListMine(userID uint) ([]model.FriendLinkApply, error) {
+ var list []model.FriendLinkApply
+ err := model.DB.Where("user_id = ?", userID).
+ Order("id DESC").
+ Limit(50).
+ Find(&list).Error
+ return list, err
+}
+
+// Cancel 撤销待审申请
+func (s *FriendLinkApplyService) Cancel(userID, id uint) error {
+ var apply model.FriendLinkApply
+ if err := model.DB.First(&apply, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrFriendLinkApplyNotFound
+ }
+ return err
+ }
+ if apply.UserID != userID {
+ return errors.New("无权操作该申请")
+ }
+ if apply.Status != model.FriendLinkApplyStatusPending {
+ return ErrFriendLinkApplyHandled
+ }
+ return model.DB.Delete(&apply).Error
+}
diff --git a/service/friend_link_enrich.go b/service/friend_link_enrich.go
new file mode 100644
index 0000000..d571656
--- /dev/null
+++ b/service/friend_link_enrich.go
@@ -0,0 +1,93 @@
+package service
+
+import (
+ "encoding/json"
+ "strings"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+)
+
+// EnrichFriendLinksLogos 为缺少 LOGO 的已发布友链,从已通过申请中按 URL 回填
+func EnrichFriendLinksLogos(links []FriendLink) []FriendLink {
+ if len(links) == 0 {
+ return links
+ }
+ needKeys := make(map[string]int)
+ for i, l := range links {
+ if strings.TrimSpace(l.Logo) != "" {
+ continue
+ }
+ key := friendLinkURLKey(l.URL)
+ if key == "" {
+ continue
+ }
+ needKeys[key] = i
+ }
+ if len(needKeys) == 0 {
+ return links
+ }
+
+ var applies []model.FriendLinkApply
+ _ = model.DB.
+ Where("status = ? AND logo <> ''", model.FriendLinkApplyStatusApproved).
+ Order("id DESC").
+ Find(&applies).Error
+
+ logoByURL := make(map[string]string, len(applies))
+ for _, a := range applies {
+ key := friendLinkURLKey(a.URL)
+ if key == "" {
+ continue
+ }
+ if _, ok := logoByURL[key]; ok {
+ continue
+ }
+ logo := normalizeFriendLinkLogoOptional(a.Logo)
+ if logo != "" {
+ logoByURL[key] = logo
+ }
+ }
+ if len(logoByURL) == 0 {
+ return links
+ }
+
+ out := make([]FriendLink, len(links))
+ copy(out, links)
+ for key, idx := range needKeys {
+ if logo, ok := logoByURL[key]; ok {
+ out[idx].Logo = logo
+ }
+ }
+ return out
+}
+
+func friendLinksLogoSnapshot(links []FriendLink) string {
+ type snap struct {
+ URL string `json:"url"`
+ Logo string `json:"logo"`
+ }
+ items := make([]snap, len(links))
+ for i, l := range links {
+ items[i] = snap{URL: friendLinkURLKey(l.URL), Logo: strings.TrimSpace(l.Logo)}
+ }
+ b, _ := json.Marshal(items)
+ return string(b)
+}
+
+// maybePersistEnrichedFriendLinks 若回填产生新 LOGO,写回 site_friend_links
+func (s *ForumSettingsService) maybePersistEnrichedFriendLinks(enriched []FriendLink) error {
+ raw := s.getString(SettingSiteFriendLinks, "[]")
+ before := parseFriendLinksJSON(raw)
+ if friendLinksLogoSnapshot(before) == friendLinksLogoSnapshot(enriched) {
+ return nil
+ }
+ normalized, err := normalizeFriendLinks(enriched)
+ if err != nil {
+ return err
+ }
+ linksJSON, err := json.Marshal(normalized)
+ if err != nil {
+ return err
+ }
+ return s.setString(SettingSiteFriendLinks, string(linksJSON))
+}
diff --git a/service/friend_link_reciprocal.go b/service/friend_link_reciprocal.go
new file mode 100644
index 0000000..9ac06c9
--- /dev/null
+++ b/service/friend_link_reciprocal.go
@@ -0,0 +1,269 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+)
+
+const (
+ reciprocalCheckTimeout = 8 * time.Second // 整次检测硬上限(DNS / 抓取 / 解析)
+ reciprocalFetchTimeout = 5 * time.Second
+ reciprocalMaxBodyBytes = 512 * 1024
+ reciprocalMaxHrefs = 4000
+)
+
+var hrefRe = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`)
+
+// VerifyReciprocalLink 检测页面 HTML 是否包含指向本站的链接
+func VerifyReciprocalLink(pageURL, ourSiteURL string) (verified bool, note string) {
+ ctx, cancel := context.WithTimeout(context.Background(), reciprocalCheckTimeout)
+ defer cancel()
+ return verifyReciprocalLink(ctx, pageURL, ourSiteURL)
+}
+
+func verifyReciprocalLink(ctx context.Context, pageURL, ourSiteURL string) (verified bool, note string) {
+ pageURL = strings.TrimSpace(pageURL)
+ ourSiteURL = strings.TrimSpace(ourSiteURL)
+ if pageURL == "" {
+ return false, "未提供回链页地址"
+ }
+ if ourSiteURL == "" {
+ return false, "本站 URL 未配置"
+ }
+
+ pageParsed, err := normalizeFriendLinkApplyURL(pageURL)
+ if err != nil {
+ return false, err.Error()
+ }
+ ourParsed, err := url.Parse(ourSiteURL)
+ if err != nil || ourParsed.Host == "" {
+ return false, "本站 URL 无效"
+ }
+ ourHost := strings.ToLower(strings.TrimSuffix(ourParsed.Host, ":443"))
+ ourHost = strings.TrimSuffix(ourHost, ":80")
+
+ if err := assertSafeFetchURL(ctx, pageParsed); err != nil {
+ return false, err.Error()
+ }
+
+ body, err := fetchHTMLBody(ctx, pageParsed)
+ if err != nil {
+ if isTimeoutErr(err) || ctx.Err() != nil {
+ return false, "访问回链页超时"
+ }
+ return false, fmt.Sprintf("无法访问回链页:%v", err)
+ }
+ if ctx.Err() != nil {
+ return false, "访问回链页超时"
+ }
+
+ if pageContainsLinkToHost(body, pageParsed, ourParsed, ourHost) {
+ return true, "已检测到本站链接"
+ }
+ return false, "未在该页面检测到指向本站的链接"
+}
+
+func isTimeoutErr(err error) bool {
+ if err == nil {
+ return false
+ }
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
+ return true
+ }
+ var ne net.Error
+ return errors.As(err, &ne) && ne.Timeout()
+}
+
+func assertSafeFetchURL(ctx context.Context, raw string) error {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return fmt.Errorf("URL 无效")
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("仅支持 http/https")
+ }
+ host := u.Hostname()
+ if host == "" {
+ return fmt.Errorf("URL 无效")
+ }
+ lower := strings.ToLower(host)
+ if lower == "localhost" || strings.HasSuffix(lower, ".localhost") || lower == "0.0.0.0" {
+ return fmt.Errorf("不允许访问内网地址")
+ }
+ ips, err := lookupHostIPs(ctx, host)
+ if err != nil {
+ if isTimeoutErr(err) {
+ return fmt.Errorf("解析域名超时")
+ }
+ return fmt.Errorf("无法解析域名")
+ }
+ for _, ip := range ips {
+ if isPrivateOrLoopbackIP(ip) {
+ return fmt.Errorf("不允许访问内网地址")
+ }
+ }
+ return nil
+}
+
+func lookupHostIPs(ctx context.Context, host string) ([]net.IP, error) {
+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
+ if err != nil {
+ return nil, err
+ }
+ ips := make([]net.IP, 0, len(addrs))
+ for _, a := range addrs {
+ if a.IP != nil {
+ ips = append(ips, a.IP)
+ }
+ }
+ return ips, nil
+}
+
+func isPrivateOrLoopbackIP(ip net.IP) bool {
+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+ return true
+ }
+ if ip4 := ip.To4(); ip4 != nil {
+ return ip4[0] == 10 ||
+ (ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
+ (ip4[0] == 192 && ip4[1] == 168) ||
+ (ip4[0] == 127) ||
+ (ip4[0] == 169 && ip4[1] == 254) ||
+ (ip4[0] == 0)
+ }
+ return false
+}
+
+func fetchHTMLBody(ctx context.Context, rawURL string) (string, error) {
+ client := &http.Client{
+ Timeout: reciprocalFetchTimeout,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 5 {
+ return fmt.Errorf("重定向过多")
+ }
+ if err := assertSafeFetchURL(req.Context(), req.URL.String()); err != nil {
+ return err
+ }
+ return nil
+ },
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return "", err
+ }
+ req.Close = true
+ req.Header.Set("User-Agent", "Jiang13Forum-FriendLinkCheck/1.0")
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 400 {
+ return "", fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+ limited := io.LimitReader(resp.Body, reciprocalMaxBodyBytes+1)
+ data, err := io.ReadAll(limited)
+ if err != nil {
+ return "", err
+ }
+ if len(data) > reciprocalMaxBodyBytes {
+ return "", fmt.Errorf("页面过大")
+ }
+ return string(data), nil
+}
+
+func pageContainsLinkToHost(html, pageURL string, ourURL *url.URL, ourHost string) bool {
+ ourHost = strings.ToLower(ourHost)
+ ourPath := strings.TrimSuffix(ourURL.Path, "/")
+ if ourPath == "" {
+ ourPath = "/"
+ }
+
+ base, err := url.Parse(pageURL)
+ if err != nil {
+ return false
+ }
+
+ checkHref := func(href string) bool {
+ href = strings.TrimSpace(href)
+ if href == "" || strings.HasPrefix(strings.ToLower(href), "javascript:") || strings.HasPrefix(strings.ToLower(href), "mailto:") {
+ return false
+ }
+ resolved, err := url.Parse(href)
+ if err != nil {
+ return false
+ }
+ resolved = base.ResolveReference(resolved)
+ host := strings.ToLower(resolved.Hostname())
+ if host == "" {
+ return false
+ }
+ host = strings.TrimSuffix(strings.TrimSuffix(host, ":443"), ":80")
+ if host != ourHost {
+ return false
+ }
+ path := strings.TrimSuffix(resolved.Path, "/")
+ if path == "" {
+ path = "/"
+ }
+ // 允许首页或完整路径匹配
+ if ourPath == "/" || path == ourPath || strings.HasPrefix(path, ourPath+"/") {
+ return true
+ }
+ return path == "/" || ourPath == path
+ }
+
+ // 逐条扫描,命中即停;限制条数避免超大页面占用过多 CPU
+ rest := html
+ for i := 0; i < reciprocalMaxHrefs; i++ {
+ loc := hrefRe.FindStringSubmatchIndex(rest)
+ if loc == nil {
+ break
+ }
+ if loc[2] >= 0 && loc[3] >= loc[2] && checkHref(rest[loc[2]:loc[3]]) {
+ return true
+ }
+ if loc[1] <= 0 {
+ break
+ }
+ rest = rest[loc[1]:]
+ }
+ // 兜底:页面源码中包含本站域名
+ lower := strings.ToLower(html)
+ if strings.Contains(lower, ourHost) {
+ return strings.Contains(lower, ourHost+"/") ||
+ strings.Contains(lower, "://"+ourHost)
+ }
+ return false
+}
+
+func normalizeFriendLinkLogo(raw string) (string, error) {
+ logo := strings.TrimSpace(raw)
+ if logo == "" {
+ return "", fmt.Errorf("请填写或上传网站 LOGO")
+ }
+ if len(logo) > maxFriendLinkURL {
+ return "", fmt.Errorf("LOGO 地址过长")
+ }
+ if strings.HasPrefix(logo, "/uploads/") {
+ return logo, nil
+ }
+ return normalizeFriendLinkApplyURL(logo)
+}
+
+// normalizeFriendLinkLogoOptional LOGO 可选(友链列表项)
+func normalizeFriendLinkLogoOptional(raw string) string {
+ logo, err := normalizeFriendLinkLogo(raw)
+ if err != nil {
+ return ""
+ }
+ return logo
+}
diff --git a/service/friend_link_reciprocal_async.go b/service/friend_link_reciprocal_async.go
new file mode 100644
index 0000000..66b963b
--- /dev/null
+++ b/service/friend_link_reciprocal_async.go
@@ -0,0 +1,62 @@
+package service
+
+import (
+ "sync"
+ "time"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+)
+
+const reciprocalCheckConcurrency = 3
+
+var (
+ reciprocalCheckMu sync.Mutex
+ reciprocalCheckGen = map[uint]uint64{}
+ reciprocalCheckSem = make(chan struct{}, reciprocalCheckConcurrency)
+)
+
+func init() {
+ for i := 0; i < reciprocalCheckConcurrency; i++ {
+ reciprocalCheckSem <- struct{}{}
+ }
+}
+
+// EnqueueReciprocalCheck 异步检测回链;同一申请多次入队时仅保留最后一次结果
+func EnqueueReciprocalCheck(applyID uint, pageURL, ourSiteURL string) {
+ reciprocalCheckMu.Lock()
+ reciprocalCheckGen[applyID]++
+ gen := reciprocalCheckGen[applyID]
+ reciprocalCheckMu.Unlock()
+
+ go runReciprocalCheck(applyID, gen, pageURL, ourSiteURL)
+}
+
+func runReciprocalCheck(applyID uint, gen uint64, pageURL, ourSiteURL string) {
+ reciprocalCheckSem <- struct{}{}
+ defer func() { <-reciprocalCheckSem }()
+
+ verified, note := VerifyReciprocalLink(pageURL, ourSiteURL)
+ now := time.Now()
+
+ reciprocalCheckMu.Lock()
+ if reciprocalCheckGen[applyID] != gen {
+ reciprocalCheckMu.Unlock()
+ return
+ }
+ reciprocalCheckMu.Unlock()
+
+ _ = model.DB.Model(&model.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{
+ "reciprocal_verified": verified,
+ "reciprocal_check_note": note,
+ "reciprocal_checked_at": now,
+ }).Error
+}
+
+// ResetReciprocalCheckState 重置为检测中,供重新检测使用
+func ResetReciprocalCheckState(applyID uint) {
+ _ = model.DB.Model(&model.FriendLinkApply{}).Where("id = ?", applyID).Updates(map[string]interface{}{
+ "reciprocal_verified": false,
+ "reciprocal_check_note": "",
+ "reciprocal_checked_at": nil,
+ }).Error
+}
diff --git a/service/friend_link_reciprocal_test.go b/service/friend_link_reciprocal_test.go
new file mode 100644
index 0000000..95cfe1b
--- /dev/null
+++ b/service/friend_link_reciprocal_test.go
@@ -0,0 +1,46 @@
+package service
+
+import (
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestPageContainsLinkToHost(t *testing.T) {
+ our, err := url.Parse("https://forum.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ page := "https://friend.example/links.html"
+ host := "forum.example.com"
+
+ if !pageContainsLinkToHost(`本站`, page, our, host) {
+ t.Fatal("应检测到绝对回链")
+ }
+ if pageContainsLinkToHost(`其他`, page, our, host) {
+ t.Fatal("不应把外站当成回链")
+ }
+}
+
+func TestPageContainsLinkToHost_LargeHTMLFast(t *testing.T) {
+ our, err := url.Parse("https://forum.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var b strings.Builder
+ b.Grow(512 * 1024)
+ for b.Len() < 400*1024 {
+ b.WriteString(`x`)
+ }
+ b.WriteString(`本站`)
+ html := b.String()
+
+ start := time.Now()
+ if !pageContainsLinkToHost(html, "https://friend.example/", our, "forum.example.com") {
+ t.Fatal("应在大量无关链接中找到回链")
+ }
+ if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
+ t.Fatalf("解析耗时过长: %s", elapsed)
+ }
+}
diff --git a/service/lottery_post.go b/service/lottery_post.go
new file mode 100644
index 0000000..83533be
--- /dev/null
+++ b/service/lottery_post.go
@@ -0,0 +1,150 @@
+package service
+
+import (
+ "crypto/rand"
+ "errors"
+ "math/big"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "gorm.io/gorm"
+)
+
+var (
+ ErrLotteryAlreadyDrawn = errors.New("已开奖")
+ ErrLotteryNotEnough = errors.New("参与人数不足")
+)
+
+// PostLotteryView 帖内抽奖视图
+type PostLotteryView struct {
+ WinnerCount int `json:"winner_count"`
+ Status string `json:"status"`
+ ParticipantCount int `json:"participant_count"`
+ Winners []PostLotteryWinnerView `json:"winners,omitempty"`
+}
+
+type PostLotteryWinnerView struct {
+ UserID uint `json:"user_id"`
+ Username string `json:"username"`
+ Nickname string `json:"nickname"`
+ CommentID uint `json:"comment_id"`
+}
+
+// InitPostLottery 初始化抽奖帖
+func InitPostLottery(postID uint, winnerCount int) error {
+ if winnerCount < 1 || winnerCount > 20 {
+ return errors.New("开奖人数需 1-20")
+ }
+ return model.DB.Model(&model.Post{}).Where("id = ?", postID).Updates(map[string]interface{}{
+ "lottery_winner_count": winnerCount,
+ "lottery_status": model.PostLotteryStatusOpen,
+ }).Error
+}
+
+// GetPostLotteryView 获取抽奖视图
+func GetPostLotteryView(post *model.Post) (*PostLotteryView, error) {
+ if post == nil || post.PostType != model.PostTypeLottery {
+ return nil, nil
+ }
+ participants, err := lotteryParticipants(post.ID, post.UserID)
+ if err != nil {
+ return nil, err
+ }
+ view := &PostLotteryView{
+ WinnerCount: post.LotteryWinnerCount,
+ Status: post.LotteryStatus,
+ ParticipantCount: len(participants),
+ }
+ if post.LotteryStatus == model.PostLotteryStatusDrawn {
+ var winners []model.PostLotteryWinner
+ model.DB.Preload("User").Where("post_id = ?", post.ID).Find(&winners)
+ for _, w := range winners {
+ view.Winners = append(view.Winners, PostLotteryWinnerView{
+ UserID: w.UserID, Username: w.User.Username, Nickname: w.User.Nickname,
+ CommentID: w.CommentID,
+ })
+ }
+ }
+ return view, nil
+}
+
+func lotteryParticipants(postID, authorID uint) ([]model.Comment, error) {
+ var comments []model.Comment
+ err := model.DB.Where("post_id = ? AND status = ? AND user_id <> ?", postID, model.ContentStatusPublished, authorID).
+ Order("id ASC").Find(&comments).Error
+ if err != nil {
+ return nil, err
+ }
+ seen := map[uint]bool{}
+ var unique []model.Comment
+ for _, c := range comments {
+ if seen[c.UserID] {
+ continue
+ }
+ seen[c.UserID] = true
+ unique = append(unique, c)
+ }
+ return unique, nil
+}
+
+// DrawPostLottery 开奖
+func DrawPostLottery(postID, operatorID uint, isAdmin bool) (*PostLotteryView, error) {
+ var post model.Post
+ if err := model.DB.First(&post, postID).Error; err != nil {
+ return nil, ErrPostNotFound
+ }
+ if post.PostType != model.PostTypeLottery {
+ return nil, errors.New("非抽奖帖")
+ }
+ if !isAdmin && post.UserID != operatorID {
+ return nil, ErrPermissionDenied
+ }
+ if post.LotteryStatus == model.PostLotteryStatusDrawn {
+ return nil, ErrLotteryAlreadyDrawn
+ }
+ participants, err := lotteryParticipants(postID, post.UserID)
+ if err != nil {
+ return nil, err
+ }
+ need := post.LotteryWinnerCount
+ if need < 1 {
+ need = 1
+ }
+ if len(participants) < need {
+ return nil, ErrLotteryNotEnough
+ }
+ picked := randomPickComments(participants, need)
+ err = model.DB.Transaction(func(tx *gorm.DB) error {
+ for _, c := range picked {
+ w := model.PostLotteryWinner{PostID: postID, UserID: c.UserID, CommentID: c.ID}
+ if err := tx.Create(&w).Error; err != nil {
+ return err
+ }
+ }
+ return tx.Model(&post).Update("lottery_status", model.PostLotteryStatusDrawn).Error
+ })
+ if err != nil {
+ return nil, err
+ }
+ post.LotteryStatus = model.PostLotteryStatusDrawn
+ return GetPostLotteryView(&post)
+}
+
+func randomPickComments(comments []model.Comment, n int) []model.Comment {
+ pool := append([]model.Comment{}, comments...)
+ out := make([]model.Comment, 0, n)
+ for i := 0; i < n && len(pool) > 0; i++ {
+ idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(pool))))
+ if err != nil {
+ idx = big.NewInt(0)
+ }
+ j := int(idx.Int64())
+ out = append(out, pool[j])
+ pool = append(pool[:j], pool[j+1:]...)
+ }
+ return out
+}
+
+// DeleteLotteryData 删帖清理
+func DeleteLotteryData(tx *gorm.DB, postID uint) {
+ tx.Where("post_id = ?", postID).Delete(&model.PostLotteryWinner{})
+}
diff --git a/service/permalink.go b/service/permalink.go
index 78e6760..987d1f7 100644
--- a/service/permalink.go
+++ b/service/permalink.go
@@ -15,9 +15,12 @@ const (
var (
permalinkExtRe = regexp.MustCompile(`(?i)^[a-z0-9]{1,16}$`)
+ slugPermalinkRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$`)
// /post/123 或 /post/123.html
postPermalinkRe = regexp.MustCompile(`^/post/(\d+)(?:\.([A-Za-z0-9]{1,16}))?/?$`)
userPermalinkRe = regexp.MustCompile(`^/user/(\d+)(?:\.([A-Za-z0-9]{1,16}))?/?$`)
+ boardPermalinkRe = regexp.MustCompile(`^/board/(\d+)(?:\.([A-Za-z0-9]{1,16}))?/?$`)
+ pagePermalinkRe = regexp.MustCompile(`^/page/([a-z0-9][a-z0-9-]*[a-z0-9]|[a-z0-9])(?:\.([A-Za-z0-9]{1,16}))?/?$`)
)
// PermalinkConfig 伪静态(固定链接)配置
@@ -74,6 +77,32 @@ func (p PermalinkConfig) UserPath(id uint) string {
return fmt.Sprintf("/user/%d%s", id, p.Suffix())
}
+// BoardPath 板块规范路径
+func (p PermalinkConfig) BoardPath(id uint) string {
+ return fmt.Sprintf("/board/%d%s", id, p.Suffix())
+}
+
+// PagePath 自定义单页规范路径
+func (p PermalinkConfig) PagePath(slug string) string {
+ slug = strings.TrimSpace(strings.ToLower(slug))
+ if slug == "" {
+ return "/"
+ }
+ return fmt.Sprintf("/page/%s%s", slug, p.Suffix())
+}
+
+// NormalizePageSlug 校验单页 slug
+func NormalizePageSlug(raw string) (string, bool) {
+ slug := strings.TrimSpace(strings.ToLower(raw))
+ if slug == "" || len(slug) > 64 {
+ return "", false
+ }
+ if !slugPermalinkRe.MatchString(slug) {
+ return "", false
+ }
+ return slug, true
+}
+
// PermalinkMatch 路径解析结果
type PermalinkMatch struct {
ID uint
@@ -105,6 +134,56 @@ func (p PermalinkConfig) MatchPostPath(path string) PermalinkMatch {
}
}
+// MatchBoardPath 解析板块公开路径
+func (p PermalinkConfig) MatchBoardPath(path string) PermalinkMatch {
+ m := boardPermalinkRe.FindStringSubmatch(path)
+ if len(m) < 2 {
+ return PermalinkMatch{}
+ }
+ id64, err := strconv.ParseUint(m[1], 10, 64)
+ if err != nil || id64 == 0 {
+ return PermalinkMatch{}
+ }
+ ext := ""
+ if len(m) > 2 {
+ ext = strings.ToLower(m[2])
+ }
+ id := uint(id64)
+ return PermalinkMatch{
+ ID: id,
+ Ext: ext,
+ Canonical: p.BoardPath(id),
+ OK: true,
+ }
+}
+
+// PagePermalinkMatch slug 型路径解析结果
+type PagePermalinkMatch struct {
+ Slug string
+ Ext string
+ Canonical string
+ OK bool
+}
+
+// MatchPagePath 解析自定义单页路径
+func (p PermalinkConfig) MatchPagePath(path string) PagePermalinkMatch {
+ m := pagePermalinkRe.FindStringSubmatch(path)
+ if len(m) < 2 {
+ return PagePermalinkMatch{}
+ }
+ slug := strings.ToLower(m[1])
+ ext := ""
+ if len(m) > 2 {
+ ext = strings.ToLower(m[2])
+ }
+ return PagePermalinkMatch{
+ Slug: slug,
+ Ext: ext,
+ Canonical: p.PagePath(slug),
+ OK: true,
+ }
+}
+
// MatchUserPath 解析用户公开路径
func (p PermalinkConfig) MatchUserPath(path string) PermalinkMatch {
m := userPermalinkRe.FindStringSubmatch(path)
diff --git a/service/poll.go b/service/poll.go
new file mode 100644
index 0000000..c798dd3
--- /dev/null
+++ b/service/poll.go
@@ -0,0 +1,280 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "gorm.io/gorm"
+)
+
+var (
+ ErrPollClosed = errors.New("投票已结束")
+ ErrPollAlreadyVoted = errors.New("已投过票")
+ ErrPollInvalidVote = errors.New("无效的投票选项")
+)
+
+const (
+ pollEndsAtMinLead = 5 * time.Minute
+ pollEndsAtMaxWindow = 365 * 24 * time.Hour
+)
+
+// PollOptionInput 创建投票时的选项
+type PollOptionInput struct {
+ Text string `json:"text"`
+}
+
+// PollView 投票帖详情视图
+type PollView struct {
+ Multi bool `json:"multi"`
+ MaxChoices int `json:"max_choices"`
+ Closed bool `json:"closed"`
+ EndsAt *time.Time `json:"ends_at,omitempty"`
+ Options []PollOptionView `json:"options"`
+ MyOptionIDs []uint `json:"my_option_ids,omitempty"`
+ TotalVotes int `json:"total_votes"`
+}
+
+type PollOptionView struct {
+ ID uint `json:"id"`
+ Text string `json:"text"`
+ VoteCount int `json:"vote_count"`
+ Percent int `json:"percent,omitempty"`
+}
+
+// CreatePollForPost 为投票帖创建投票配置与选项
+func CreatePollForPost(tx *gorm.DB, postID uint, multi bool, maxChoices int, endsAt *time.Time, options []PollOptionInput) error {
+ if len(options) < 2 || len(options) > 10 {
+ return errors.New("投票选项需 2-10 个")
+ }
+ if !multi {
+ maxChoices = 1
+ } else if maxChoices < 1 || maxChoices > len(options) {
+ maxChoices = len(options)
+ }
+ poll := model.Poll{PostID: postID, Multi: multi, MaxChoices: maxChoices, EndsAt: endsAt}
+ if err := tx.Create(&poll).Error; err != nil {
+ return err
+ }
+ for i, opt := range options {
+ text := strings.TrimSpace(opt.Text)
+ if text == "" {
+ return errors.New("投票选项不能为空")
+ }
+ if len([]rune(text)) > 64 {
+ return errors.New("投票选项最多 64 字")
+ }
+ row := model.PollOption{PostID: postID, Text: text, SortOrder: i}
+ if err := tx.Create(&row).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// ParsePollOptionsJSON 解析发帖表单中的 poll_options JSON
+func ParsePollOptionsJSON(raw string) ([]PollOptionInput, bool, int, *time.Time, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil, false, 1, nil, errors.New("投票选项不能为空")
+ }
+ var payload struct {
+ Multi bool `json:"multi"`
+ MaxChoices int `json:"max_choices"`
+ EndsAt string `json:"ends_at"`
+ Options []PollOptionInput `json:"options"`
+ }
+ if err := json.Unmarshal([]byte(raw), &payload); err != nil {
+ return nil, false, 1, nil, err
+ }
+ endsAt, err := parsePollEndsAt(payload.EndsAt)
+ if err != nil {
+ return nil, false, 1, nil, err
+ }
+ return payload.Options, payload.Multi, payload.MaxChoices, endsAt, nil
+}
+
+func parsePollEndsAt(raw string) (*time.Time, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil, nil
+ }
+ var parsed time.Time
+ var ok bool
+ for _, layout := range []string{
+ time.RFC3339Nano,
+ time.RFC3339,
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+ } {
+ if t, err := time.Parse(layout, raw); err == nil {
+ parsed = t
+ ok = true
+ break
+ }
+ }
+ if !ok {
+ return nil, errors.New("投票截止时间格式无效")
+ }
+ now := time.Now()
+ if !parsed.After(now.Add(pollEndsAtMinLead)) {
+ return nil, errors.New("投票截止时间须晚于当前时间至少 5 分钟")
+ }
+ if parsed.After(now.Add(pollEndsAtMaxWindow)) {
+ return nil, errors.New("投票截止时间不能超过 365 天")
+ }
+ utc := parsed.UTC()
+ return &utc, nil
+}
+
+// closePollIfExpired 若已过截止时间则自动关闭投票
+func closePollIfExpired(postID uint) error {
+ var poll model.Poll
+ if err := model.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil {
+ return err
+ }
+ if poll.Closed || poll.EndsAt == nil {
+ return nil
+ }
+ if time.Now().Before(*poll.EndsAt) {
+ return nil
+ }
+ res := model.DB.Model(&poll).Where("post_id = ? AND closed = ?", postID, false).Update("closed", true)
+ return res.Error
+}
+
+// GetPollView 获取投票视图
+func GetPollView(postID, viewerID uint) (*PollView, error) {
+ if err := closePollIfExpired(postID); err != nil {
+ return nil, err
+ }
+ var poll model.Poll
+ if err := model.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil {
+ return nil, err
+ }
+ var opts []model.PollOption
+ if err := model.DB.Where("post_id = ?", postID).Order("sort_order ASC, id ASC").Find(&opts).Error; err != nil {
+ return nil, err
+ }
+ total := 0
+ for _, o := range opts {
+ total += o.VoteCount
+ }
+ showResults := poll.Closed
+ var myIDs []uint
+ if viewerID > 0 {
+ var votes []model.PollVote
+ model.DB.Where("post_id = ? AND user_id = ?", postID, viewerID).Find(&votes)
+ for _, v := range votes {
+ myIDs = append(myIDs, v.OptionID)
+ }
+ if len(myIDs) > 0 {
+ showResults = true
+ }
+ }
+ views := make([]PollOptionView, len(opts))
+ for i, o := range opts {
+ v := PollOptionView{ID: o.ID, Text: o.Text, VoteCount: o.VoteCount}
+ if showResults && total > 0 {
+ v.Percent = o.VoteCount * 100 / total
+ }
+ views[i] = v
+ }
+ return &PollView{
+ Multi: poll.Multi, MaxChoices: poll.MaxChoices, Closed: poll.Closed,
+ EndsAt: poll.EndsAt, Options: views, MyOptionIDs: myIDs, TotalVotes: total,
+ }, nil
+}
+
+// VotePoll 用户投票
+func VotePoll(postID, userID uint, optionIDs []uint) error {
+ if userID == 0 {
+ return ErrPermissionDenied
+ }
+ if err := closePollIfExpired(postID); err != nil {
+ return err
+ }
+ var poll model.Poll
+ if err := model.DB.Where("post_id = ?", postID).First(&poll).Error; err != nil {
+ return err
+ }
+ if poll.Closed {
+ return ErrPollClosed
+ }
+ var existing int64
+ model.DB.Model(&model.PollVote{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&existing)
+ if existing > 0 {
+ return ErrPollAlreadyVoted
+ }
+ if len(optionIDs) == 0 {
+ return ErrPollInvalidVote
+ }
+ if !poll.Multi && len(optionIDs) != 1 {
+ return errors.New("本投票为单选")
+ }
+ if poll.Multi && len(optionIDs) > poll.MaxChoices {
+ return errors.New("超出最多可选数")
+ }
+ seen := map[uint]bool{}
+ for _, oid := range optionIDs {
+ if oid == 0 || seen[oid] {
+ return ErrPollInvalidVote
+ }
+ seen[oid] = true
+ var opt model.PollOption
+ if err := model.DB.Where("id = ? AND post_id = ?", oid, postID).First(&opt).Error; err != nil {
+ return ErrPollInvalidVote
+ }
+ }
+ return model.DB.Transaction(func(tx *gorm.DB) error {
+ for _, oid := range optionIDs {
+ v := model.PollVote{PostID: postID, OptionID: oid, UserID: userID}
+ if err := tx.Create(&v).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&model.PollOption{}).Where("id = ?", oid).
+ UpdateColumn("vote_count", gorm.Expr("vote_count + 1")).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+// ClosePoll 结束投票
+func ClosePoll(postID, userID uint, isAdmin bool, postAuthorID uint) error {
+ if !isAdmin && userID != postAuthorID {
+ return ErrPermissionDenied
+ }
+ res := model.DB.Model(&model.Poll{}).Where("post_id = ?", postID).Update("closed", true)
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return errors.New("投票不存在")
+ }
+ return nil
+}
+
+// LockPollOptions 编辑时锁定选项(已发布帖不允许改选项文案)
+func LockPollOptions(postID uint) bool {
+ var n int64
+ model.DB.Model(&model.PollVote{}).Where("post_id = ?", postID).Count(&n)
+ return n > 0
+}
+
+// EnsurePollExists 检查投票帖是否有 poll 记录
+func EnsurePollExists(postID uint) bool {
+ var n int64
+ model.DB.Model(&model.Poll{}).Where("post_id = ?", postID).Count(&n)
+ return n > 0
+}
+
+// DeletePollData 删帖时清理投票数据
+func DeletePollData(tx *gorm.DB, postID uint) {
+ tx.Where("post_id = ?", postID).Delete(&model.PollVote{})
+ tx.Where("post_id = ?", postID).Delete(&model.PollOption{})
+ tx.Where("post_id = ?", postID).Delete(&model.Poll{})
+}
diff --git a/service/post.go b/service/post.go
index 8bc30dc..8e42741 100644
--- a/service/post.go
+++ b/service/post.go
@@ -23,11 +23,21 @@ func normalizePostType(raw string) string {
switch strings.TrimSpace(raw) {
case model.PostTypeQuestion:
return model.PostTypeQuestion
+ case model.PostTypePoll:
+ return model.PostTypePoll
+ case model.PostTypeBounty:
+ return model.PostTypeBounty
+ case model.PostTypeLottery:
+ return model.PostTypeLottery
default:
return model.PostTypeNormal
}
}
+func isSpecialPostType(t string) bool {
+ return t == model.PostTypePoll || t == model.PostTypeBounty || t == model.PostTypeLottery
+}
+
type PostListQuery struct {
BoardID uint
UserID uint // >0 时仅返回该用户的帖子
@@ -479,6 +489,13 @@ func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool,
if strings.TrimSpace(postType) != "" {
nextType = normalizePostType(postType)
}
+ // 不允许修改特殊帖子类型(含 poll→normal、normal→poll)
+ if isSpecialPostType(post.PostType) && nextType != post.PostType {
+ return errors.New("不能修改特殊帖子类型")
+ }
+ if isSpecialPostType(nextType) && post.PostType != nextType {
+ return errors.New("不能改为特殊帖子类型")
+ }
nextResolved := post.QuestionResolved
if nextType != model.PostTypeQuestion {
nextResolved = false
@@ -646,6 +663,11 @@ func (s *PostService) Delete(userID, postID uint, isAdmin bool) error {
return ErrPostNotFound
}
return model.DB.Transaction(func(tx *gorm.DB) error {
+ if err := RefundBountyIfOpen(tx, &post); err != nil {
+ return err
+ }
+ DeletePollData(tx, postID)
+ DeleteLotteryData(tx, postID)
if err := tx.Where("post_id = ?", postID).Delete(&model.Comment{}).Error; err != nil {
return err
}
diff --git a/service/post_special.go b/service/post_special.go
new file mode 100644
index 0000000..1e91bd1
--- /dev/null
+++ b/service/post_special.go
@@ -0,0 +1,69 @@
+package service
+
+import (
+ "errors"
+ "strconv"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "gorm.io/gorm"
+)
+
+// PostCreateExtras 特殊帖创建附加参数
+type PostCreateExtras struct {
+ PollOptionsJSON string
+ BountyPoints int
+ LotteryWinnerCount int
+}
+
+// FinalizeSpecialPostCreate 创建帖后初始化投票/悬赏/抽奖
+func FinalizeSpecialPostCreate(post *model.Post, userID uint, extras PostCreateExtras) error {
+ if post == nil {
+ return errors.New("帖子不存在")
+ }
+ return model.DB.Transaction(func(tx *gorm.DB) error {
+ switch post.PostType {
+ case model.PostTypePoll:
+ opts, multi, maxChoices, endsAt, err := ParsePollOptionsJSON(extras.PollOptionsJSON)
+ if err != nil {
+ return err
+ }
+ return CreatePollForPost(tx, post.ID, multi, maxChoices, endsAt, opts)
+ case model.PostTypeBounty:
+ if extras.BountyPoints < 1 {
+ return ErrBountyInvalidPoint
+ }
+ if err := tx.Model(post).Updates(map[string]interface{}{
+ "bounty_points": extras.BountyPoints,
+ "bounty_status": model.BountyStatusOpen,
+ }).Error; err != nil {
+ return err
+ }
+ return EscrowBounty(tx, userID, post.ID, extras.BountyPoints)
+ case model.PostTypeLottery:
+ count := extras.LotteryWinnerCount
+ if count < 1 {
+ count = 1
+ }
+ if count > 20 {
+ return errors.New("开奖人数最多 20")
+ }
+ return tx.Model(post).Updates(map[string]interface{}{
+ "lottery_winner_count": count,
+ "lottery_status": model.PostLotteryStatusOpen,
+ }).Error
+ default:
+ return nil
+ }
+ })
+}
+
+// ParsePostExtrasFromForm 从表单解析特殊帖参数
+func ParsePostExtrasFromForm(pollJSON, bountyRaw, lotteryRaw string) PostCreateExtras {
+ bounty, _ := strconv.Atoi(bountyRaw)
+ lottery, _ := strconv.Atoi(lotteryRaw)
+ return PostCreateExtras{
+ PollOptionsJSON: pollJSON,
+ BountyPoints: bounty,
+ LotteryWinnerCount: lottery,
+ }
+}
diff --git a/service/ratelimit.go b/service/ratelimit.go
index 8b9738d..c78826b 100644
--- a/service/ratelimit.go
+++ b/service/ratelimit.go
@@ -23,8 +23,8 @@ func NewRateLimiter(settings *ForumSettingsService) *RateLimiter {
// Allow 检查 action+key 是否允许操作
func (r *RateLimiter) Allow(action, key string) bool {
- limit := r.settings.RateLimitFor(action)
- window := time.Duration(r.settings.RateLimitWindowSec()) * time.Second
+ limit := r.limitFor(action)
+ window := r.windowFor(action)
if limit <= 0 {
return true
}
@@ -50,12 +50,26 @@ func (r *RateLimiter) Allow(action, key string) bool {
return true
}
+func (r *RateLimiter) limitFor(action string) int {
+ if action == "friend_link" {
+ return 5
+ }
+ return r.settings.RateLimitFor(action)
+}
+
+func (r *RateLimiter) windowFor(action string) time.Duration {
+ if action == "friend_link" {
+ return time.Hour
+ }
+ return time.Duration(r.settings.RateLimitWindowSec()) * time.Second
+}
+
func (r *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
r.mu.Lock()
- window := time.Duration(r.settings.RateLimitWindowSec()) * time.Second
- cutoff := time.Now().Add(-window * 2)
+ // 使用最大窗口清理,覆盖友链 1 小时窗口
+ cutoff := time.Now().Add(-time.Hour * 2)
for k, times := range r.records {
var valid []time.Time
for _, t := range times {
diff --git a/service/seo.go b/service/seo.go
index aab6721..d9b137c 100644
--- a/service/seo.go
+++ b/service/seo.go
@@ -99,8 +99,16 @@ func DisplayName(u *model.User) string {
return strings.TrimSpace(u.Username)
}
-// QueryBoardHome 板块首页相对路径
-func QueryBoardHome(boardID uint) string {
+// QueryBoardHome 板块首页相对路径(规范伪静态路径)
+func QueryBoardHome(boardID uint, p PermalinkConfig) string {
+ if boardID == 0 {
+ return "/"
+ }
+ return p.BoardPath(boardID)
+}
+
+// LegacyQueryBoardHome 旧版 query 形式(/?board=id),仅用于 301 重定向
+func LegacyQueryBoardHome(boardID uint) string {
if boardID == 0 {
return "/"
}
diff --git a/service/settings.go b/service/settings.go
index 90a3b34..cf2ea0f 100644
--- a/service/settings.go
+++ b/service/settings.go
@@ -13,7 +13,7 @@ import (
// 论坛设置键名
const (
- SettingPostEditWindowHours = "post_edit_window_hours"
+ SettingPostEditWindowHours = "post_edit_window_hours"
SettingCommentEditWindowMinutes = "comment_edit_window_minutes"
SettingRateLimitPost = "rate_limit_post"
@@ -39,6 +39,11 @@ const (
SettingOpenPostsInNewTab = "open_posts_in_new_tab"
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
+ SettingAsideShowTagCloud = "aside_show_tag_cloud"
+ SettingAsideShowRecentComments = "aside_show_recent_comments"
+ SettingAsideShowFriendLinks = "aside_show_friend_links"
+ SettingAsideWidgets = "aside_widgets"
+ SettingFeedListStyle = "feed_list_style"
// 伪静态键名见 permalink.go:SettingPermalinkEnabled / SettingPermalinkExt
@@ -73,17 +78,18 @@ const (
SettingStorageForcePathStyle = "storage_force_path_style"
SettingStorageImageDelivery = "storage_image_delivery"
- SettingSiteName = "site_name"
- SettingSiteSlogan = "site_slogan"
- SettingSiteDescription = "site_description"
- SettingSiteKeywords = "site_keywords"
- SettingSiteLogoMark = "site_logo_mark"
- SettingSiteLogo = "site_logo"
- SettingSiteFavicon = "site_favicon"
- SettingSiteOGImage = "site_og_image"
- SettingSiteICPBeian = "site_icp_beian"
- SettingSiteICPBeianURL = "site_icp_beian_url"
- SettingSiteFriendLinks = "site_friend_links"
+ SettingSiteName = "site_name"
+ SettingSiteSlogan = "site_slogan"
+ SettingSiteDescription = "site_description"
+ SettingSiteKeywords = "site_keywords"
+ SettingSiteLogoMark = "site_logo_mark"
+ SettingSiteLogo = "site_logo"
+ SettingSiteFavicon = "site_favicon"
+ SettingSiteOGImage = "site_og_image"
+ SettingSiteICPBeian = "site_icp_beian"
+ SettingSiteICPBeianURL = "site_icp_beian_url"
+ SettingSiteFriendLinks = "site_friend_links"
+ SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check"
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
pageSizeAPIMax = 100
@@ -118,10 +124,35 @@ type ForumLimits struct {
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
+ AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
+ AsideShowRecentComments bool `json:"aside_show_recent_comments"`
+ AsideShowFriendLinks bool `json:"aside_show_friend_links"`
+ AsideWidgets []AsideWidget `json:"aside_widgets"`
+
+ FeedListStyle string `json:"feed_list_style"`
+
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
}
+// AsideWidget 右侧栏可选组件
+type AsideWidget struct {
+ ID string `json:"id"`
+ Enabled bool `json:"enabled"`
+}
+
+const (
+ AsideWidgetTagCloud = "tag_cloud"
+ AsideWidgetRecentComments = "recent_comments"
+ AsideWidgetFriendLinks = "friend_links"
+)
+
+var asideWidgetDefaultOrder = []string{
+ AsideWidgetTagCloud,
+ AsideWidgetRecentComments,
+ AsideWidgetFriendLinks,
+}
+
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
type ForumLimitsPublic struct {
PostTitleMax int `json:"post_title_max"`
@@ -140,15 +171,22 @@ type ForumLimitsPublic struct {
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
+ AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
+ AsideShowRecentComments bool `json:"aside_show_recent_comments"`
+ AsideShowFriendLinks bool `json:"aside_show_friend_links"`
+ AsideWidgets []AsideWidget `json:"aside_widgets"`
+
+ FeedListStyle string `json:"feed_list_style"`
+
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
}
type settingDef struct {
- key string
+ key string
defaultVal string
- min int
- max int // 0 表示不限制上限
+ min int
+ max int // 0 表示不限制上限
}
var forumSettingDefs = []settingDef{
@@ -180,6 +218,17 @@ var forumSettingDefs = []settingDef{
{SettingOpenContentLinksInNewTab, "1", 0, 1},
}
+var feedSettingDefaults = map[string]string{
+ SettingFeedListStyle: "title",
+}
+
+var asideSettingDefaults = map[string]string{
+ SettingAsideShowTagCloud: "0",
+ SettingAsideShowRecentComments: "0",
+ SettingAsideShowFriendLinks: "1",
+ SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true}]`,
+}
+
var mailSettingDefaults = map[string]string{
SettingSMTPEnabled: "0",
SettingSMTPHost: "",
@@ -219,6 +268,10 @@ var storageSettingDefaults = map[string]string{
SettingStorageImageDelivery: ImageDeliveryWebP,
}
+var friendLinkSettingDefaults = map[string]string{
+ SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
+}
+
var siteBrandingDefaults = map[string]string{
SettingSiteName: "姜十三论坛",
SettingSiteSlogan: "拾三一隅,自在交流",
@@ -245,10 +298,11 @@ const (
defaultICPBeianURL = "https://beian.miit.gov.cn/"
)
-// FriendLink 页脚友情链接
+// FriendLink 友情链接
type FriendLink struct {
Name string `json:"name"`
URL string `json:"url"`
+ Logo string `json:"logo,omitempty"`
}
// SiteBranding 站点品牌配置(名称、Logo、Favicon、页脚等)
@@ -264,6 +318,7 @@ type SiteBranding struct {
ICPBeian string `json:"icp_beian"`
ICPBeianURL string `json:"icp_beian_url"`
FriendLinks []FriendLink `json:"friend_links"`
+ SiteURL string `json:"site_url,omitempty" gorm:"-"` // 公开站点根 URL,仅 API 填充
}
// DocumentTitle 浏览器标签标题:站点名 - 副标题(标语)
@@ -343,6 +398,20 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal})
}
}
+ for key, val := range feedSettingDefaults {
+ var count int64
+ model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ if count == 0 {
+ model.DB.Create(&model.ForumSetting{Key: key, Value: val})
+ }
+ }
+ for key, val := range asideSettingDefaults {
+ var count int64
+ model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ if count == 0 {
+ model.DB.Create(&model.ForumSetting{Key: key, Value: val})
+ }
+ }
for key, val := range mailSettingDefaults {
var count int64
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
@@ -378,6 +447,13 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
}
}
+ for key, val := range friendLinkSettingDefaults {
+ var count int64
+ model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
+ if count == 0 {
+ model.DB.Create(&model.ForumSetting{Key: key, Value: val})
+ }
+ }
}
func (s *ForumSettingsService) getString(key, fallback string) string {
@@ -426,6 +502,8 @@ func (s *ForumSettingsService) setInt(key string, value int) error {
func (s *ForumSettingsService) Limits() ForumLimits {
permalink := s.Permalink()
+ widgets := s.AsideWidgets()
+ bools := asideBoolsFromWidgets(widgets)
return ForumLimits{
PostEditWindowHours: s.PostEditWindowHours(),
CommentEditWindowMinutes: s.CommentEditWindowMinutes(),
@@ -454,6 +532,13 @@ func (s *ForumSettingsService) Limits() ForumLimits {
OpenPostsInNewTab: s.OpenPostsInNewTab(),
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
+ AsideShowTagCloud: bools.tagCloud,
+ AsideShowRecentComments: bools.recentComments,
+ AsideShowFriendLinks: bools.friendLinks,
+ AsideWidgets: widgets,
+
+ FeedListStyle: s.FeedListStyle(),
+
PermalinkEnabled: permalink.Enabled,
PermalinkExt: permalink.Ext,
}
@@ -478,6 +563,13 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
OpenPostsInNewTab: limits.OpenPostsInNewTab,
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
+ AsideShowTagCloud: limits.AsideShowTagCloud,
+ AsideShowRecentComments: limits.AsideShowRecentComments,
+ AsideShowFriendLinks: limits.AsideShowFriendLinks,
+ AsideWidgets: limits.AsideWidgets,
+
+ FeedListStyle: limits.FeedListStyle,
+
PermalinkEnabled: limits.PermalinkEnabled,
PermalinkExt: limits.PermalinkExt,
}
@@ -487,21 +579,21 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
updates := map[string]int{
SettingPostEditWindowHours: in.PostEditWindowHours,
SettingCommentEditWindowMinutes: in.CommentEditWindowMinutes,
- SettingRateLimitPost: in.RateLimitPost,
- SettingRateLimitComment: in.RateLimitComment,
- SettingRateLimitRegister: in.RateLimitRegister,
- SettingRateLimitLogin: in.RateLimitLogin,
- SettingRateLimitWindow: in.RateLimitWindowSec,
- SettingPostTitleMax: in.PostTitleMax,
- SettingPostTagsMax: in.PostTagsMax,
- SettingPostContentMax: in.PostContentMax,
- SettingCommentMax: in.CommentMax,
- SettingSearchKeywordMin: in.SearchKeywordMin,
- SettingSearchKeywordMax: in.SearchKeywordMax,
- SettingPageSizeDefault: in.PageSizeDefault,
- SettingPasswordMinLen: in.PasswordMinLen,
- SettingAvatarMaxMB: in.AvatarMaxMB,
- SettingSignatureMax: in.SignatureMax,
+ SettingRateLimitPost: in.RateLimitPost,
+ SettingRateLimitComment: in.RateLimitComment,
+ SettingRateLimitRegister: in.RateLimitRegister,
+ SettingRateLimitLogin: in.RateLimitLogin,
+ SettingRateLimitWindow: in.RateLimitWindowSec,
+ SettingPostTitleMax: in.PostTitleMax,
+ SettingPostTagsMax: in.PostTagsMax,
+ SettingPostContentMax: in.PostContentMax,
+ SettingCommentMax: in.CommentMax,
+ SettingSearchKeywordMin: in.SearchKeywordMin,
+ SettingSearchKeywordMax: in.SearchKeywordMax,
+ SettingPageSizeDefault: in.PageSizeDefault,
+ SettingPasswordMinLen: in.PasswordMinLen,
+ SettingAvatarMaxMB: in.AvatarMaxMB,
+ SettingSignatureMax: in.SignatureMax,
}
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
return ErrInvalidSetting
@@ -511,9 +603,17 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
return err
}
}
+ widgets := NormalizeAsideWidgets(in.AsideWidgets)
+ if len(widgets) == 0 {
+ widgets = asideWidgetsFromBools(in.AsideShowTagCloud, in.AsideShowRecentComments, in.AsideShowFriendLinks)
+ }
+ bools := asideBoolsFromWidgets(widgets)
boolUpdates := map[string]bool{
SettingOpenPostsInNewTab: in.OpenPostsInNewTab,
SettingOpenContentLinksInNewTab: in.OpenContentLinksInNewTab,
+ SettingAsideShowTagCloud: bools.tagCloud,
+ SettingAsideShowRecentComments: bools.recentComments,
+ SettingAsideShowFriendLinks: bools.friendLinks,
SettingPermalinkEnabled: in.PermalinkEnabled,
}
for key, on := range boolUpdates {
@@ -525,6 +625,13 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
return err
}
}
+ widgetsJSON, err := json.Marshal(widgets)
+ if err != nil {
+ return err
+ }
+ if err := s.setString(SettingAsideWidgets, string(widgetsJSON)); err != nil {
+ return err
+ }
ext, ok := NormalizePermalinkExt(in.PermalinkExt)
if !ok {
return ErrInvalidSetting
@@ -532,6 +639,13 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
if err := s.setString(SettingPermalinkExt, ext); err != nil {
return err
}
+ style, ok := NormalizeFeedListStyle(in.FeedListStyle)
+ if !ok {
+ return ErrInvalidSetting
+ }
+ if err := s.setString(SettingFeedListStyle, style); err != nil {
+ return err
+ }
return nil
}
@@ -584,6 +698,126 @@ func (s *ForumSettingsService) OpenContentLinksInNewTab() bool {
return s.getString(SettingOpenContentLinksInNewTab, "1") == "1"
}
+// FriendLinkReciprocalCheckEnabled 是否启用友链回链检测;缺省为关闭
+func (s *ForumSettingsService) FriendLinkReciprocalCheckEnabled() bool {
+ return s.getString(SettingFriendLinkReciprocalCheck, "0") == "1"
+}
+
+func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
+ v := "0"
+ if enabled {
+ v = "1"
+ }
+ return s.setString(SettingFriendLinkReciprocalCheck, v)
+}
+
+type asideWidgetBools struct {
+ tagCloud bool
+ recentComments bool
+ friendLinks bool
+}
+
+func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
+ return []AsideWidget{
+ {ID: AsideWidgetTagCloud, Enabled: tagCloud},
+ {ID: AsideWidgetRecentComments, Enabled: recentComments},
+ {ID: AsideWidgetFriendLinks, Enabled: friendLinks},
+ }
+}
+
+func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
+ out := asideWidgetBools{}
+ for _, w := range widgets {
+ switch w.ID {
+ case AsideWidgetTagCloud:
+ out.tagCloud = w.Enabled
+ case AsideWidgetRecentComments:
+ out.recentComments = w.Enabled
+ case AsideWidgetFriendLinks:
+ out.friendLinks = w.Enabled
+ }
+ }
+ return out
+}
+
+func isValidAsideWidgetID(id string) bool {
+ switch id {
+ case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetFriendLinks:
+ return true
+ default:
+ return false
+ }
+}
+
+// NormalizeAsideWidgets 校验并补全右侧栏组件列表(顺序保留,缺失项按默认顺序追加)
+func NormalizeAsideWidgets(in []AsideWidget) []AsideWidget {
+ seen := make(map[string]bool, len(asideWidgetDefaultOrder))
+ out := make([]AsideWidget, 0, len(asideWidgetDefaultOrder))
+ for _, w := range in {
+ id := strings.TrimSpace(w.ID)
+ if !isValidAsideWidgetID(id) || seen[id] {
+ continue
+ }
+ seen[id] = true
+ out = append(out, AsideWidget{ID: id, Enabled: w.Enabled})
+ }
+ for _, id := range asideWidgetDefaultOrder {
+ if seen[id] {
+ continue
+ }
+ out = append(out, AsideWidget{ID: id, Enabled: false})
+ }
+ return out
+}
+
+func (s *ForumSettingsService) AsideWidgets() []AsideWidget {
+ raw := strings.TrimSpace(s.getString(SettingAsideWidgets, ""))
+ if raw != "" {
+ var widgets []AsideWidget
+ if err := json.Unmarshal([]byte(raw), &widgets); err == nil {
+ normalized := NormalizeAsideWidgets(widgets)
+ if len(normalized) > 0 {
+ return normalized
+ }
+ }
+ }
+ return asideWidgetsFromBools(s.AsideShowTagCloud(), s.AsideShowRecentComments(), s.AsideShowFriendLinks())
+}
+
+func (s *ForumSettingsService) AsideShowTagCloud() bool {
+ return s.getString(SettingAsideShowTagCloud, "0") == "1"
+}
+
+func (s *ForumSettingsService) AsideShowRecentComments() bool {
+ return s.getString(SettingAsideShowRecentComments, "0") == "1"
+}
+
+func (s *ForumSettingsService) AsideShowFriendLinks() bool {
+ return s.getString(SettingAsideShowFriendLinks, "1") == "1"
+}
+
+// NormalizeFeedListStyle 校验首页列表样式
+func NormalizeFeedListStyle(v string) (string, bool) {
+ switch strings.TrimSpace(strings.ToLower(v)) {
+ case "title", "":
+ return "title", true
+ case "excerpt":
+ return "excerpt", true
+ case "thumbnail":
+ return "thumbnail", true
+ default:
+ return "", false
+ }
+}
+
+func (s *ForumSettingsService) FeedListStyle() string {
+ v, ok := NormalizeFeedListStyle(s.getString(SettingFeedListStyle, feedSettingDefaults[SettingFeedListStyle]))
+ if !ok {
+ return "title"
+ }
+ return v
+}
+
// MailConfig 读取 SMTP 配置(密码不回显明文)
func (s *ForumSettingsService) MailConfig() MailConfig {
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))
@@ -903,6 +1137,11 @@ func (s *ForumSettingsService) SiteBranding() SiteBranding {
mark = string(runes[0])
}
links := parseFriendLinksJSON(s.getString(SettingSiteFriendLinks, "[]"))
+ links = EnrichFriendLinksLogos(links)
+ if err := s.maybePersistEnrichedFriendLinks(links); err != nil {
+ // 回填失败不阻断读取
+ _ = err
+ }
return SiteBranding{
Name: name,
Slogan: strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
@@ -1111,7 +1350,7 @@ func normalizeFriendLinks(in []FriendLink) ([]FriendLink, error) {
if scheme != "http" && scheme != "https" {
return nil, ErrInvalidSetting
}
- out = append(out, FriendLink{Name: name, URL: href})
+ out = append(out, FriendLink{Name: name, URL: href, Logo: normalizeFriendLinkLogoOptional(item.Logo)})
}
if out == nil {
out = []FriendLink{}
diff --git a/service/site_page.go b/service/site_page.go
new file mode 100644
index 0000000..054d82b
--- /dev/null
+++ b/service/site_page.go
@@ -0,0 +1,176 @@
+package service
+
+import (
+ "errors"
+ "strings"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+)
+
+var (
+ ErrSitePageNotFound = errors.New("单页不存在")
+ ErrSitePageSlugUsed = errors.New("slug 已被占用")
+)
+
+// SitePageService 自定义单页
+type SitePageService struct {
+ filter *SensitiveFilter
+}
+
+func NewSitePageService(filter *SensitiveFilter) *SitePageService {
+ return &SitePageService{filter: filter}
+}
+
+// SitePageSummary 公开列表摘要
+type SitePageSummary struct {
+ ID uint `json:"id"`
+ Title string `json:"title"`
+ Slug string `json:"slug"`
+ ShowInFooter bool `json:"show_in_footer"`
+ ShowInNav bool `json:"show_in_nav"`
+ SortOrder int `json:"sort_order"`
+}
+
+func (s *SitePageService) ListPublished() ([]SitePageSummary, error) {
+ var rows []model.SitePage
+ err := model.DB.Where("published = ?", true).
+ Order("sort_order ASC, id ASC").
+ Find(&rows).Error
+ if err != nil {
+ return nil, err
+ }
+ out := make([]SitePageSummary, len(rows))
+ for i, p := range rows {
+ out[i] = SitePageSummary{
+ ID: p.ID, Title: p.Title, Slug: p.Slug,
+ ShowInFooter: p.ShowInFooter, ShowInNav: p.ShowInNav, SortOrder: p.SortOrder,
+ }
+ }
+ return out, nil
+}
+
+func (s *SitePageService) ListAll() ([]model.SitePage, error) {
+ var rows []model.SitePage
+ err := model.DB.Order("sort_order ASC, id ASC").Find(&rows).Error
+ if err != nil {
+ return nil, err
+ }
+ return rows, nil
+}
+
+func (s *SitePageService) GetBySlug(slug string, allowUnpublished bool) (*model.SitePage, error) {
+ slug, ok := NormalizePageSlug(slug)
+ if !ok {
+ return nil, ErrSitePageNotFound
+ }
+ var page model.SitePage
+ q := model.DB.Where("slug = ?", slug)
+ if !allowUnpublished {
+ q = q.Where("published = ?", true)
+ }
+ if err := q.First(&page).Error; err != nil {
+ return nil, ErrSitePageNotFound
+ }
+ page.Content = SanitizePostHTML(page.Content)
+ return &page, nil
+}
+
+func (s *SitePageService) GetByID(id uint) (*model.SitePage, error) {
+ var page model.SitePage
+ if err := model.DB.First(&page, id).Error; err != nil {
+ return nil, ErrSitePageNotFound
+ }
+ return &page, nil
+}
+
+type SitePageInput struct {
+ Title string `json:"title"`
+ Slug string `json:"slug"`
+ Content string `json:"content"`
+ Published bool `json:"published"`
+ SortOrder int `json:"sort_order"`
+ ShowInFooter bool `json:"show_in_footer"`
+ ShowInNav bool `json:"show_in_nav"`
+}
+
+func (s *SitePageService) Create(in SitePageInput) (*model.SitePage, error) {
+ page, err := s.normalizeInput(in)
+ if err != nil {
+ return nil, err
+ }
+ var exists int64
+ model.DB.Model(&model.SitePage{}).Where("slug = ?", page.Slug).Count(&exists)
+ if exists > 0 {
+ return nil, ErrSitePageSlugUsed
+ }
+ if err := model.DB.Create(page).Error; err != nil {
+ return nil, err
+ }
+ return page, nil
+}
+
+func (s *SitePageService) Update(id uint, in SitePageInput) error {
+ page, err := s.GetByID(id)
+ if err != nil {
+ return err
+ }
+ next, err := s.normalizeInput(in)
+ if err != nil {
+ return err
+ }
+ var exists int64
+ model.DB.Model(&model.SitePage{}).Where("slug = ? AND id <> ?", next.Slug, id).Count(&exists)
+ if exists > 0 {
+ return ErrSitePageSlugUsed
+ }
+ return model.DB.Model(page).Updates(map[string]interface{}{
+ "title": next.Title,
+ "slug": next.Slug,
+ "content": next.Content,
+ "published": next.Published,
+ "sort_order": next.SortOrder,
+ "show_in_footer": next.ShowInFooter,
+ "show_in_nav": next.ShowInNav,
+ }).Error
+}
+
+func (s *SitePageService) Delete(id uint) error {
+ res := model.DB.Delete(&model.SitePage{}, id)
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return ErrSitePageNotFound
+ }
+ return nil
+}
+
+func (s *SitePageService) ListSitemap(limit int) ([]model.SitePage, error) {
+ if limit <= 0 {
+ limit = 500
+ }
+ var rows []model.SitePage
+ err := model.DB.Where("published = ?", true).
+ Order("updated_at DESC").Limit(limit).Find(&rows).Error
+ return rows, err
+}
+
+func (s *SitePageService) normalizeInput(in SitePageInput) (*model.SitePage, error) {
+ title := s.filter.Filter(strings.TrimSpace(in.Title))
+ slug, ok := NormalizePageSlug(in.Slug)
+ if !ok {
+ return nil, errors.New("slug 格式无效(2-64 位小写字母、数字、连字符)")
+ }
+ content := s.filter.Filter(SanitizePostHTML(in.Content))
+ if title == "" {
+ return nil, errors.New("标题不能为空")
+ }
+ if content == "" {
+ return nil, errors.New("正文不能为空")
+ }
+ return &model.SitePage{
+ Title: title, Slug: slug, Content: content,
+ Published: in.Published, SortOrder: in.SortOrder,
+ ShowInFooter: in.ShowInFooter, ShowInNav: in.ShowInNav,
+ }, nil
+}