feat(posts): 类型帖截止时间、抽奖奖品表与悬赏发帖体验
统一投票/悬赏/抽奖截止与状态展示,抽奖支持多奖品行;发帖页悬赏积分对齐表格输入,并在余额不足时于底部就地提示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
9
frontend/AGENTS.md
Normal file
9
frontend/AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
frontend/CLAUDE.md
Normal file
1
frontend/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -182,30 +182,106 @@ body {
|
||||
box-shadow: var(--shadow);
|
||||
overflow: visible;
|
||||
}
|
||||
.j13-compose-toolbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--panel-2));
|
||||
position: sticky;
|
||||
top: 3.5rem;
|
||||
z-index: 20;
|
||||
border-radius: var(--r) var(--r) 0 0;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.j13-compose-toolbar {
|
||||
padding: 0.85rem 1.75rem;
|
||||
}
|
||||
}
|
||||
.j13-compose-toolbar-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.j13-compose-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.j13-compose-body {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 1.35rem 1.35rem 0.85rem;
|
||||
gap: 1.1rem;
|
||||
padding: 1.15rem 1.15rem 0.75rem;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.j13-compose-body {
|
||||
padding: 1.5rem 1.75rem 0.9rem;
|
||||
gap: 1.15rem;
|
||||
padding: 1.25rem 1.4rem 0.85rem;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
}
|
||||
/* 发帖分区卡片:轻分隔,不过度割裂也不糊成一片 */
|
||||
.j13-compose-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem 1.1rem 1.1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg) 70%, var(--panel));
|
||||
}
|
||||
.j13-compose-section-title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-compose-section-writing {
|
||||
background: var(--panel);
|
||||
border-color: color-mix(in srgb, var(--line) 85%, var(--accent) 15%);
|
||||
box-shadow: 0 1px 0 color-mix(in srgb, var(--ink) 3.5%, transparent);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
/* 类型面板已在分区内,去掉二次描边避免套娃 */
|
||||
.j13-compose-section .j13-compose-typepanel {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.j13-compose-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 1rem 1.5rem 1.25rem;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.j13-compose-actions-error {
|
||||
flex: 1 1 12rem;
|
||||
margin: 0;
|
||||
max-width: min(100%, 28rem);
|
||||
}
|
||||
.j13-compose-actions-end {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.j13-compose-actions {
|
||||
padding-left: 2rem;
|
||||
@@ -271,6 +347,54 @@ body {
|
||||
border: 1px dashed color-mix(in srgb, var(--accent) 28%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
|
||||
}
|
||||
.j13-lottery-prize-table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.j13-lottery-prize-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.j13-lottery-prize-table th,
|
||||
.j13-lottery-prize-table td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.j13-lottery-prize-table th {
|
||||
font-weight: 600;
|
||||
color: var(--ink-2);
|
||||
background: var(--panel-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.j13-lottery-prize-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.j13-lottery-prize-table .col-idx {
|
||||
width: 2.5rem;
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.j13-lottery-prize-table .col-count {
|
||||
width: 6.5rem;
|
||||
}
|
||||
.j13-lottery-prize-table .col-actions {
|
||||
width: 4.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
.j13-lottery-prize-table .j13-compose-field {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.j13-lottery-prize-table .j13-compose-field-count {
|
||||
width: 100%;
|
||||
max-width: 5.5rem;
|
||||
}
|
||||
.j13-compose-attach-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -286,6 +410,474 @@ body {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
/* 抽奖卡:对齐截图结构,落在站点石墨/令牌体系内 */
|
||||
/* 类型帖交互卡(投票 / 悬赏 / 抽奖)共用壳 */
|
||||
.j13-typed-card {
|
||||
padding: 16px 18px 14px;
|
||||
}
|
||||
.j13-typed-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
.j13-typed-head-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.j13-typed-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.j13-typed-title svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.j13-typed-title.is-question {
|
||||
color: var(--ink);
|
||||
}
|
||||
.j13-typed-title.is-poll {
|
||||
color: var(--accent);
|
||||
}
|
||||
.j13-typed-title.is-bounty {
|
||||
color: var(--clay);
|
||||
}
|
||||
.j13-typed-title.is-lottery {
|
||||
color: var(--gold);
|
||||
}
|
||||
.j13-typed-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.j13-typed-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.j13-typed-meta,
|
||||
.j13-typed-empty {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-typed-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 16px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-typed-foot-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.j13-typed-foot-status svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.j13-typed-foot-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.j13-bounty-points {
|
||||
font-weight: 700;
|
||||
color: var(--clay);
|
||||
}
|
||||
.j13-poll-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.j13-poll-option {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.j13-poll-option:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.j13-poll-option:not(:disabled):hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
|
||||
}
|
||||
.j13-poll-option.is-on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.j13-poll-option-bar {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
opacity: 0.28;
|
||||
background: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.j13-poll-option-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.j13-poll-option-text {
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.j13-poll-option-stat {
|
||||
flex-shrink: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-poll-voters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 6px 0 0;
|
||||
padding: 0 2px;
|
||||
list-style: none;
|
||||
}
|
||||
.j13-poll-voter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px 2px 3px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.j13-poll-voter:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.j13-lottery-card {
|
||||
padding: 16px 18px 14px;
|
||||
}
|
||||
.j13-lottery-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
.j13-lottery-head-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.j13-lottery-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--gold);
|
||||
}
|
||||
.j13-lottery-title svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.j13-lottery-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.j13-lottery-meta,
|
||||
.j13-lottery-empty {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
/* 截止倒计时:投票 / 悬赏 / 抽奖共用 */
|
||||
.j13-deadline-countdown {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px 11px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
|
||||
}
|
||||
.j13-deadline-countdown.is-expired {
|
||||
border-color: color-mix(in srgb, var(--ink-3) 35%, var(--line));
|
||||
background: color-mix(in srgb, var(--tint) 70%, var(--panel));
|
||||
}
|
||||
.j13-deadline-countdown-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
color: var(--accent);
|
||||
}
|
||||
.j13-deadline-countdown.is-expired .j13-deadline-countdown-label {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.j13-deadline-countdown-digits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.j13-deadline-countdown-unit {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
padding: 6px 10px 5px;
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.j13-deadline-countdown-unit.is-sec {
|
||||
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent-soft) 45%, var(--panel));
|
||||
}
|
||||
.j13-deadline-countdown-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(1.35rem, 3.2vw, 1.75rem);
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.j13-deadline-countdown-suf {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-deadline-countdown-unit.is-sec .j13-deadline-countdown-num {
|
||||
color: var(--accent);
|
||||
}
|
||||
.j13-deadline-countdown-expired {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.j13-deadline-countdown-ends {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dark .j13-deadline-countdown-unit {
|
||||
background: var(--panel-2);
|
||||
box-shadow: none;
|
||||
}
|
||||
.j13-deadline-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.j13-deadline-picker-label {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--ink);
|
||||
}
|
||||
.j13-deadline-picker-modes {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
background: var(--tint);
|
||||
align-self: flex-start;
|
||||
}
|
||||
.j13-deadline-mode {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.j13-deadline-mode.is-on {
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.j13-deadline-mode:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.j13-deadline-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.j13-deadline-custom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-width: 22rem;
|
||||
}
|
||||
.j13-deadline-datetime {
|
||||
width: 100%;
|
||||
}
|
||||
/* 奖品 / 中奖者:横向排布,放不下换行 */
|
||||
.j13-lottery-prize-row,
|
||||
.j13-lottery-winner-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
gap: 8px 10px;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.j13-lottery-prize-row > li,
|
||||
.j13-lottery-winner-row > li {
|
||||
min-width: 0;
|
||||
}
|
||||
/* 奖品胶囊:淡金底 + 深金字 + 奖杯徽标 */
|
||||
.j13-lottery-prize-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: min(100%, 28rem);
|
||||
padding: 6px 13px 6px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
background: var(--gold-soft);
|
||||
color: var(--gold);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gold) 28%, transparent);
|
||||
}
|
||||
.j13-lottery-prize-chip svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.j13-lottery-prize-chip-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.j13-lottery-prize-chip-count {
|
||||
margin-left: 0.3em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
.j13-lottery-winners-label {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-lottery-winner-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* 中奖者:横向胶囊卡片,内容定宽、可换行 */
|
||||
.j13-lottery-winner {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
width: min(100%, 15.5rem);
|
||||
min-height: 52px;
|
||||
padding: 8px 14px 8px 8px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.j13-lottery-winner-avatar {
|
||||
grid-column: 1;
|
||||
}
|
||||
.j13-lottery-winner-body {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.j13-lottery-winner-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.j13-lottery-winner-name:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.j13-lottery-winner-prize {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--gold);
|
||||
}
|
||||
.dark .j13-lottery-winner {
|
||||
background: var(--panel-2);
|
||||
box-shadow: none;
|
||||
}
|
||||
.j13-lottery-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 16px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-lottery-foot-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.j13-lottery-foot-status svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.j13-lottery-foot-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.j13-interact-card.is-unsolved {
|
||||
background: color-mix(in srgb, var(--danger) 7%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--danger) 22%, var(--line));
|
||||
@@ -303,51 +895,41 @@ body {
|
||||
border-color: color-mix(in srgb, var(--ok) 30%, var(--line));
|
||||
}
|
||||
|
||||
/* 正文下方已采纳答案:问答资料卡 */
|
||||
/* 正文下方已采纳答案:与类型帖 typed-card 同框,配色克制 */
|
||||
.j13-accepted-answer {
|
||||
margin-top: 1.75rem;
|
||||
padding: 1.15rem 1.25rem 1.25rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--ok) 28%, var(--line));
|
||||
background: color-mix(in srgb, var(--ok) 7%, var(--panel));
|
||||
box-shadow: inset 3px 0 0 0 var(--ok);
|
||||
}
|
||||
.dark .j13-accepted-answer {
|
||||
background: color-mix(in srgb, var(--ok) 12%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--ok) 36%, var(--line));
|
||||
}
|
||||
.j13-accepted-answer__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px 16px;
|
||||
margin-bottom: 0.9rem;
|
||||
padding-bottom: 0.85rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--ok) 18%, var(--line));
|
||||
}
|
||||
.j13-accepted-answer__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.j13-accepted-answer__status {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
line-height: 1.3;
|
||||
.j13-accepted-answer.is-question,
|
||||
.j13-accepted-answer.is-bounty {
|
||||
/* 覆盖旧绿底/左边条;沿用 interact-card 面板 */
|
||||
border-color: var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: none;
|
||||
}
|
||||
.j13-accepted-answer__author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.j13-accepted-answer__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
}
|
||||
.j13-accepted-answer__body {
|
||||
min-width: 0;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
/* 正文下方类型特殊块(投票/悬赏/抽奖/问答状态) */
|
||||
.j13-interact-below {
|
||||
@@ -382,6 +964,10 @@ body {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.j13-compose-field-invalid {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
}
|
||||
|
||||
.j13-compose-title {
|
||||
width: 100%;
|
||||
@@ -1129,18 +1715,11 @@ a.j13-mention-token:hover {
|
||||
background: color-mix(in srgb, #0284c7 12%, var(--panel));
|
||||
color: #0369a1;
|
||||
}
|
||||
/* 已结束 / 已过期 / 已开奖 / 已截止:统一灰胶囊(标识已到期) */
|
||||
.badge-status-closed {
|
||||
background: color-mix(in srgb, #5b6b8c 12%, var(--panel));
|
||||
color: #5b6b8c;
|
||||
}
|
||||
.badge-status-expired {
|
||||
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
|
||||
color: var(--danger);
|
||||
}
|
||||
.badge-status-drawn {
|
||||
background: color-mix(in srgb, #7c5cbf 14%, var(--panel));
|
||||
color: #6a4aad;
|
||||
}
|
||||
.dark .badge-status-unsolved {
|
||||
background: rgba(255, 107, 96, 0.14);
|
||||
color: #fca5a5;
|
||||
@@ -1157,14 +1736,6 @@ a.j13-mention-token:hover {
|
||||
background: rgba(170, 182, 207, 0.14);
|
||||
color: #aab6cf;
|
||||
}
|
||||
.dark .badge-status-expired {
|
||||
background: color-mix(in srgb, var(--danger) 20%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.dark .badge-status-drawn {
|
||||
background: rgba(192, 168, 245, 0.16);
|
||||
color: #c0a8f5;
|
||||
}
|
||||
|
||||
/* 评论区成就徽标:实心底,比类型/状态浅底更醒目 */
|
||||
.badge-accepted {
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props = {
|
||||
initialTypeMeta?: string;
|
||||
initialAttachments?: PostAttachment[];
|
||||
canEditPollOptions?: boolean;
|
||||
canEditLotteryPrizes?: boolean;
|
||||
boardName: string;
|
||||
boardId: number;
|
||||
boardColorIndex?: number;
|
||||
@@ -30,6 +31,7 @@ export default function EditPostClient({
|
||||
initialTypeMeta,
|
||||
initialAttachments,
|
||||
canEditPollOptions,
|
||||
canEditLotteryPrizes,
|
||||
boardName,
|
||||
boardId,
|
||||
boardColorIndex = 0,
|
||||
@@ -47,6 +49,7 @@ export default function EditPostClient({
|
||||
initialTypeMeta={initialTypeMeta}
|
||||
initialAttachments={initialAttachments}
|
||||
canEditPollOptions={canEditPollOptions}
|
||||
canEditLotteryPrizes={canEditLotteryPrizes}
|
||||
boardName={boardName}
|
||||
boardId={boardId}
|
||||
boardColorIndex={boardColorIndex}
|
||||
|
||||
@@ -65,6 +65,7 @@ export default async function EditPostPage({ params }: PageProps) {
|
||||
initialTypeMeta={post.type_meta}
|
||||
initialAttachments={post.attachments || []}
|
||||
canEditPollOptions={post.poll?.can_edit_options !== false}
|
||||
canEditLotteryPrizes={post.lottery?.can_edit_prizes !== false}
|
||||
boardName={post.board?.name || "未知板块"}
|
||||
boardId={post.board_id}
|
||||
boardColorIndex={post.board?.color_index ?? 0}
|
||||
|
||||
@@ -714,8 +714,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
<AcceptedAnswerCard
|
||||
answer={acceptedAnswer}
|
||||
variant={acceptedAnswerVariant}
|
||||
postId={post.id}
|
||||
canReopen={!!post.question?.can_reopen}
|
||||
solvedAt={
|
||||
post.question?.solved_at ||
|
||||
post.bounty?.accepted_answer?.created_at ||
|
||||
acceptedAnswer.created_at
|
||||
}
|
||||
/>
|
||||
) : questionState ? (
|
||||
<QuestionStatusCard postId={post.id} state={questionState} />
|
||||
|
||||
@@ -1,76 +1,83 @@
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { CheckCircle2, CircleHelp, Coins } from "lucide-react";
|
||||
import type { AcceptedAnswer } from "@/lib/api";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import MarkdownBody from "@/components/MarkdownBody";
|
||||
import AcceptedAnswerReopen from "@/components/AcceptedAnswerReopen";
|
||||
|
||||
/** 正文下方的已采纳答案卡:头像 + 昵称 + 全文;问答已解决时并入状态与「重新打开」 */
|
||||
/** 正文下方已采纳答案:对齐类型帖 typed-card;已采纳后不可重新打开 */
|
||||
export default async function AcceptedAnswerCard({
|
||||
answer,
|
||||
variant = "question",
|
||||
postId,
|
||||
canReopen = false,
|
||||
solvedAt,
|
||||
}: {
|
||||
answer: AcceptedAnswer;
|
||||
/** 问答用「最佳答案」,悬赏用「已采纳」 */
|
||||
variant?: "question" | "bounty";
|
||||
postId?: number;
|
||||
canReopen?: boolean;
|
||||
/** 解决/采纳时刻;缺省用答案创建时间 */
|
||||
solvedAt?: string;
|
||||
}) {
|
||||
const name = answer.user.nickname || answer.user.username || "用户";
|
||||
const label = variant === "bounty" ? "已采纳" : "最佳答案";
|
||||
const showReopen = variant === "question" && canReopen && !!postId;
|
||||
const isQuestion = variant === "question";
|
||||
const title = isQuestion ? "问答" : "悬赏";
|
||||
const statusLabel = isQuestion ? "已解决" : "已采纳";
|
||||
const answerLabel = isQuestion ? "最佳答案" : "采纳答案";
|
||||
const endTime = solvedAt || answer.created_at;
|
||||
const endLabel = formatEndsAt(endTime);
|
||||
|
||||
return (
|
||||
<section className="j13-accepted-answer" aria-label={variant === "question" ? "问答 · 已解决" : label}>
|
||||
<header className="j13-accepted-answer__head">
|
||||
<div className="j13-accepted-answer__meta min-w-0">
|
||||
{variant === "question" && (
|
||||
<p className="j13-accepted-answer__status">问答 · 已解决</p>
|
||||
)}
|
||||
<div className="j13-accepted-answer__author min-w-0">
|
||||
<span className="badge-accepted shrink-0">
|
||||
<CheckCircle2 size={13} aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
<Link
|
||||
href={`/u/${answer.user.id}`}
|
||||
className="shrink-0"
|
||||
aria-label={name}
|
||||
>
|
||||
<Avatar name={name} src={answer.user.avatar || undefined} size={36} />
|
||||
</Link>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<Link
|
||||
href={`/u/${answer.user.id}`}
|
||||
className="truncate text-[14px] font-semibold hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
<p className="meta text-[12px] flex items-center gap-1.5 flex-wrap">
|
||||
<time dateTime={answer.created_at}>
|
||||
{new Date(answer.created_at).toLocaleString("zh-CN")}
|
||||
</time>
|
||||
{answer.floor > 0 && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<a
|
||||
href={`#comment-${answer.id}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
#{answer.floor}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
className={`j13-interact-card j13-typed-card j13-accepted-answer${isQuestion ? " is-question" : " is-bounty"}`}
|
||||
aria-label={`${title} · ${statusLabel}`}
|
||||
>
|
||||
<div className="j13-typed-head">
|
||||
<div className="j13-typed-head-main">
|
||||
<h2 className={`j13-typed-title ${isQuestion ? "is-question" : "is-bounty"}`}>
|
||||
{isQuestion ? (
|
||||
<CircleHelp size={16} strokeWidth={2.25} aria-hidden />
|
||||
) : (
|
||||
<Coins size={16} strokeWidth={2.25} aria-hidden />
|
||||
)}
|
||||
{title}
|
||||
</h2>
|
||||
<span className="badge-status badge-status-solved shrink-0">{statusLabel}</span>
|
||||
</div>
|
||||
{showReopen && <AcceptedAnswerReopen postId={postId!} />}
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="j13-accepted-answer__author">
|
||||
<span className="j13-accepted-answer__chip">
|
||||
<CheckCircle2 size={12} strokeWidth={2.25} aria-hidden />
|
||||
{answerLabel}
|
||||
</span>
|
||||
<Link href={`/u/${answer.user.id}`} className="shrink-0" aria-label={name}>
|
||||
<Avatar name={name} src={answer.user.avatar || undefined} size={32} />
|
||||
</Link>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<Link
|
||||
href={`/u/${answer.user.id}`}
|
||||
className="truncate text-[13.5px] font-semibold hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
<p className="meta text-[12px] flex items-center gap-1.5 flex-wrap !mb-0">
|
||||
<time dateTime={answer.created_at}>
|
||||
{new Date(answer.created_at).toLocaleString("zh-CN")}
|
||||
</time>
|
||||
{answer.floor > 0 && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<a
|
||||
href={`#comment-${answer.id}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
#{answer.floor}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="j13-accepted-answer__body">
|
||||
{answer.deleted ? (
|
||||
@@ -81,6 +88,25 @@ export default async function AcceptedAnswerCard({
|
||||
<MarkdownBody content={answer.content} className="prose-content prose-comment" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="j13-typed-foot">
|
||||
<span className="j13-typed-foot-status">
|
||||
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
|
||||
{isQuestion ? "已采纳答案" : "悬赏已发放"}
|
||||
</span>
|
||||
{endLabel ? (
|
||||
<span className="j13-typed-foot-time">结束时间: {endLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatEndsAt(iso?: string) {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleString("zh-CN");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiReopenQuestion } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
/** 已采纳答案卡上的「重新打开」——并入正文下方资料卡,避免与上方状态条重复 */
|
||||
export default function AcceptedAnswerReopen({ postId }: { postId: number }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const reopen = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiReopenQuestion(String(postId));
|
||||
if (res.post) {
|
||||
toast("已重新打开", "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "操作失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0 ml-auto"
|
||||
disabled={busy}
|
||||
onClick={() => void reopen()}
|
||||
>
|
||||
{busy ? "处理中…" : "重新打开"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
130
frontend/components/DeadlinePicker.tsx
Normal file
130
frontend/components/DeadlinePicker.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
/** 发帖截止时间:快捷天数 + 自定义时刻 */
|
||||
|
||||
export type DeadlineMode = "preset" | "custom";
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
/** datetime-local 值(本地时区) */
|
||||
export function toLocalInputValue(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 默认自定义:从现在起 +N 天 */
|
||||
export function defaultCustomLocal(days = 7): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
d.setSeconds(0, 0);
|
||||
return toLocalInputValue(d);
|
||||
}
|
||||
|
||||
/** 本地 datetime-local → RFC3339(UTC) */
|
||||
export function localInputToRFC3339(local: string): string | null {
|
||||
const t = Date.parse(local);
|
||||
if (!Number.isFinite(t)) return null;
|
||||
return new Date(t).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
}
|
||||
|
||||
export function formatLocalPreview(local: string): string {
|
||||
const t = Date.parse(local);
|
||||
if (!Number.isFinite(t)) return "";
|
||||
try {
|
||||
return new Date(t).toLocaleString("zh-CN");
|
||||
} catch {
|
||||
return local;
|
||||
}
|
||||
}
|
||||
|
||||
export default function DeadlinePicker({
|
||||
label = "截止时间",
|
||||
presets,
|
||||
mode,
|
||||
onModeChange,
|
||||
days,
|
||||
onDaysChange,
|
||||
customLocal,
|
||||
onCustomLocalChange,
|
||||
disabled,
|
||||
hint,
|
||||
}: {
|
||||
label?: string;
|
||||
presets: readonly number[];
|
||||
mode: DeadlineMode;
|
||||
onModeChange: (m: DeadlineMode) => void;
|
||||
days: number;
|
||||
onDaysChange: (d: number) => void;
|
||||
customLocal: string;
|
||||
onCustomLocalChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
}) {
|
||||
const minLocal = toLocalInputValue(new Date(Date.now() + 60 * 60 * 1000));
|
||||
const maxLocal = toLocalInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000));
|
||||
|
||||
return (
|
||||
<div className="j13-deadline-picker">
|
||||
<div className="j13-deadline-picker-label">{label}</div>
|
||||
<div className="j13-deadline-picker-modes" role="tablist" aria-label={label}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "preset"}
|
||||
className={`j13-deadline-mode${mode === "preset" ? " is-on" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onModeChange("preset")}
|
||||
>
|
||||
快捷时长
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "custom"}
|
||||
className={`j13-deadline-mode${mode === "custom" ? " is-on" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onModeChange("custom");
|
||||
if (!customLocal) onCustomLocalChange(defaultCustomLocal(days || 7));
|
||||
}}
|
||||
>
|
||||
指定时刻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === "preset" ? (
|
||||
<div className="j13-deadline-presets">
|
||||
{presets.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`btn btn-sm ${days === d ? "btn-primary" : "btn-line"}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onDaysChange(d)}
|
||||
>
|
||||
{d} 天
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="j13-deadline-custom">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="j13-compose-field j13-deadline-datetime"
|
||||
value={customLocal}
|
||||
min={minLocal}
|
||||
max={maxLocal}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onCustomLocalChange(e.target.value)}
|
||||
aria-label="指定截止时刻"
|
||||
/>
|
||||
{customLocal ? (
|
||||
<p className="meta text-[12.5px] !mb-0">将于 {formatLocalPreview(customLocal)} 截止</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{hint ? <p className="meta text-[12px] !mb-0">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Gift, Trophy, CheckCircle2, Timer, BarChart3, Coins, CircleHelp } from "lucide-react";
|
||||
import {
|
||||
apiVotePoll,
|
||||
apiClosePoll,
|
||||
apiRefundBounty,
|
||||
apiCloseLottery,
|
||||
apiDrawLottery,
|
||||
apiSolveQuestion,
|
||||
apiReopenQuestion,
|
||||
@@ -32,12 +31,13 @@ export default function PostInteractPanels({
|
||||
lottery?: LotteryState | null;
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
// 问答状态改在正文下方展示(未解决 / 已解决资料卡),此处不再放
|
||||
if (!poll && !bounty && !lottery) return null;
|
||||
// 问答 / 已采纳悬赏改在正文下方 AcceptedAnswerCard,此处不再放
|
||||
const showBounty = !!bounty && !bounty.accepted_comment_id;
|
||||
if (!poll && !showBounty && !lottery) return null;
|
||||
return (
|
||||
<div className="j13-interact-below space-y-4">
|
||||
{poll && <PollPanel postId={postId} state={poll} loggedIn={loggedIn} />}
|
||||
{bounty && <BountyPanel postId={postId} state={bounty} />}
|
||||
{showBounty && <BountyPanel postId={postId} state={bounty!} />}
|
||||
{lottery && <LotteryPanel postId={postId} state={lottery} />}
|
||||
</div>
|
||||
);
|
||||
@@ -52,6 +52,129 @@ function formatEndsAt(iso?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 底栏右侧:进行中显示截止时间,结束后显示结束时间 */
|
||||
function TypedFootTime({
|
||||
endsAt,
|
||||
endedAt,
|
||||
ended,
|
||||
}: {
|
||||
endsAt?: string;
|
||||
endedAt?: string;
|
||||
ended?: boolean;
|
||||
}) {
|
||||
if (ended) {
|
||||
const t = endedAt || endsAt;
|
||||
if (!t) return null;
|
||||
return <span className="j13-typed-foot-time">结束时间: {formatEndsAt(t)}</span>;
|
||||
}
|
||||
if (!endsAt) return null;
|
||||
return <span className="j13-typed-foot-time">截止时间: {formatEndsAt(endsAt)}</span>;
|
||||
}
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(Math.max(0, n)).padStart(2, "0");
|
||||
}
|
||||
|
||||
function remainParts(endsAtMs: number, now: number) {
|
||||
const ms = Math.max(0, endsAtMs - now);
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
return {
|
||||
days: Math.floor(totalSec / 86400),
|
||||
hours: Math.floor((totalSec % 86400) / 3600),
|
||||
mins: Math.floor((totalSec % 3600) / 60),
|
||||
secs: totalSec % 60,
|
||||
expired: ms <= 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 未截止倒计时:最小单位秒,每秒刷新 */
|
||||
function DeadlineCountdown({
|
||||
endsAt,
|
||||
activeLabel = "距截止还剩",
|
||||
expiredLabel = "已到截止时间",
|
||||
expiredHint = "等待处理",
|
||||
}: {
|
||||
endsAt: string;
|
||||
activeLabel?: string;
|
||||
expiredLabel?: string;
|
||||
expiredHint?: string;
|
||||
}) {
|
||||
const endsMs = Date.parse(endsAt);
|
||||
const [now, setNow] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(endsMs)) return;
|
||||
const tick = () => setNow(Date.now());
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [endsMs]);
|
||||
|
||||
if (!Number.isFinite(endsMs)) return null;
|
||||
|
||||
if (now === null) {
|
||||
return (
|
||||
<div className="j13-deadline-countdown" aria-hidden="true">
|
||||
<div className="j13-deadline-countdown-label">
|
||||
<Timer size={15} strokeWidth={2.25} aria-hidden />
|
||||
{activeLabel}
|
||||
</div>
|
||||
<div className="j13-deadline-countdown-digits">
|
||||
{["天", "时", "分", "秒"].map((suf) => (
|
||||
<span key={suf} className={`j13-deadline-countdown-unit${suf === "秒" ? " is-sec" : ""}`}>
|
||||
<span className="j13-deadline-countdown-num tabular-nums">--</span>
|
||||
<span className="j13-deadline-countdown-suf">{suf}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="j13-deadline-countdown-ends">截止于 {formatEndsAt(endsAt)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const r = remainParts(endsMs, now);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`j13-deadline-countdown${r.expired ? " is-expired" : ""}`}
|
||||
role="timer"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div className="j13-deadline-countdown-label">
|
||||
<Timer size={15} strokeWidth={2.25} aria-hidden />
|
||||
{r.expired ? expiredLabel : activeLabel}
|
||||
</div>
|
||||
{r.expired ? (
|
||||
<p className="j13-deadline-countdown-expired">{expiredHint}</p>
|
||||
) : (
|
||||
<div
|
||||
className="j13-deadline-countdown-digits"
|
||||
aria-label={`还剩 ${r.days} 天 ${r.hours} 时 ${r.mins} 分 ${r.secs} 秒`}
|
||||
>
|
||||
<span className="j13-deadline-countdown-unit">
|
||||
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.days)}</span>
|
||||
<span className="j13-deadline-countdown-suf">天</span>
|
||||
</span>
|
||||
<span className="j13-deadline-countdown-unit">
|
||||
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.hours)}</span>
|
||||
<span className="j13-deadline-countdown-suf">时</span>
|
||||
</span>
|
||||
<span className="j13-deadline-countdown-unit">
|
||||
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.mins)}</span>
|
||||
<span className="j13-deadline-countdown-suf">分</span>
|
||||
</span>
|
||||
<span className="j13-deadline-countdown-unit is-sec">
|
||||
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.secs)}</span>
|
||||
<span className="j13-deadline-countdown-suf">秒</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="j13-deadline-countdown-ends">截止于 {formatEndsAt(endsAt)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */
|
||||
export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) {
|
||||
const router = useRouter();
|
||||
@@ -70,57 +193,80 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = state.solved
|
||||
? { cls: "badge-status badge-status-solved", label: "已解决" }
|
||||
: { cls: "badge-status badge-status-unsolved", label: "未解决" };
|
||||
|
||||
const footLeft = state.solved
|
||||
? state.accepted_comment_id
|
||||
? "已采纳答案"
|
||||
: "已标记解决"
|
||||
: "等待解答";
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`j13-interact-card j13-question-below${state.solved ? " is-solved" : " is-unsolved"}`}
|
||||
aria-label={`问答 · ${state.solved ? "已解决" : "未解决"}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
问答 · {state.solved ? "已解决" : "未解决"}
|
||||
<section className="j13-interact-card j13-typed-card" aria-label={`问答 · ${statusBadge.label}`}>
|
||||
<div className="j13-typed-head">
|
||||
<div className="j13-typed-head-main">
|
||||
<h2 className="j13-typed-title is-question">
|
||||
<CircleHelp size={16} strokeWidth={2.25} aria-hidden />
|
||||
问答
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
{state.solved
|
||||
? state.accepted_comment_id
|
||||
? (
|
||||
<>
|
||||
已采纳{" "}
|
||||
<a
|
||||
href={`#comment-${state.accepted_comment_id}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
#{state.accepted_floor || "?"}
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
: "作者已标记为已解决"
|
||||
: "可在评论旁采纳答案,或手动标记已解决"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.can_solve && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiSolveQuestion(String(postId)), "已标记为已解决")}
|
||||
>
|
||||
标记已解决
|
||||
</button>
|
||||
)}
|
||||
{state.can_reopen && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiReopenQuestion(String(postId)), "已重新打开")}
|
||||
>
|
||||
重新打开
|
||||
</button>
|
||||
)}
|
||||
<span className={statusBadge.cls}>{statusBadge.label}</span>
|
||||
</div>
|
||||
{(state.can_solve || state.can_reopen) && (
|
||||
<div className="j13-typed-actions">
|
||||
{state.can_solve && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiSolveQuestion(String(postId)), "已标记为已解决")}
|
||||
>
|
||||
标记已解决
|
||||
</button>
|
||||
)}
|
||||
{state.can_reopen && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiReopenQuestion(String(postId)), "已重新打开")}
|
||||
>
|
||||
重新打开
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="j13-typed-meta">
|
||||
{state.solved
|
||||
? state.accepted_comment_id
|
||||
? (
|
||||
<>
|
||||
已采纳{" "}
|
||||
<a
|
||||
href={`#comment-${state.accepted_comment_id}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
#{state.accepted_floor || "?"}
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
: "作者已标记为已解决"
|
||||
: "可在评论旁采纳答案,或手动标记已解决"}
|
||||
</p>
|
||||
|
||||
<div className="j13-typed-foot">
|
||||
<span className="j13-typed-foot-status">
|
||||
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
|
||||
{footLeft}
|
||||
</span>
|
||||
<TypedFootTime
|
||||
ended={state.solved}
|
||||
endedAt={state.solved_at || state.accepted_answer?.created_at}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -139,6 +285,7 @@ function PollPanel({
|
||||
const [sel, setSel] = useState<number[]>(state.my_options || []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const showResults = state.closed || (state.my_options && state.my_options.length > 0);
|
||||
const hasVoted = (state.my_options?.length ?? 0) > 0;
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
if (!state.can_vote) return;
|
||||
@@ -183,19 +330,56 @@ function PollPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = state.closed
|
||||
? { cls: "badge-status badge-status-closed", label: "已结束" }
|
||||
: { cls: "badge-status badge-status-open", label: "进行中" };
|
||||
|
||||
const footLeft = state.closed
|
||||
? "已结束投票"
|
||||
: hasVoted
|
||||
? "你已完成投票"
|
||||
: "投票进行中";
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
投票{state.multi ? "(多选)" : "(单选)"}
|
||||
{state.anonymous ? " · 匿名" : " · 实名"}
|
||||
</h2>
|
||||
<span className="meta text-[12.5px]">
|
||||
{state.voters} 人参与 · {state.total_votes} 票
|
||||
{state.closed ? " · 已结束" : ""}
|
||||
</span>
|
||||
<section className="j13-interact-card j13-typed-card" aria-label="投票">
|
||||
<div className="j13-typed-head">
|
||||
<div className="j13-typed-head-main">
|
||||
<h2 className="j13-typed-title is-poll">
|
||||
<BarChart3 size={16} strokeWidth={2.25} aria-hidden />
|
||||
投票
|
||||
</h2>
|
||||
<span className={statusBadge.cls}>{statusBadge.label}</span>
|
||||
</div>
|
||||
{state.can_close && (
|
||||
<div className="j13-typed-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void close()}
|
||||
>
|
||||
结束投票
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
|
||||
<p className="j13-typed-meta">
|
||||
{state.multi ? "多选" : "单选"}
|
||||
{state.anonymous ? " · 匿名" : " · 实名"}
|
||||
{" · "}
|
||||
{state.voters} 人参与 · {state.total_votes} 票
|
||||
</p>
|
||||
|
||||
{!state.closed && state.ends_at ? (
|
||||
<DeadlineCountdown
|
||||
endsAt={state.ends_at}
|
||||
expiredLabel="已到截止时间"
|
||||
expiredHint="投票即将关闭"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ul className="j13-poll-options">
|
||||
{state.options.map((o) => {
|
||||
const checked = sel.includes(o.index);
|
||||
const namedVoters = !state.anonymous && showResults ? o.voters || [] : [];
|
||||
@@ -205,43 +389,32 @@ function PollPanel({
|
||||
type="button"
|
||||
disabled={!state.can_vote || busy}
|
||||
onClick={() => toggle(o.index)}
|
||||
className="w-full text-left rounded-[12px] px-3 py-2.5 relative overflow-hidden transition-colors"
|
||||
style={{
|
||||
border: `1px solid ${checked ? "var(--accent)" : "var(--line)"}`,
|
||||
background: checked ? "var(--accent-soft)" : "var(--panel)",
|
||||
}}
|
||||
className={`j13-poll-option${checked ? " is-on" : ""}`}
|
||||
>
|
||||
{showResults && (
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 opacity-30"
|
||||
style={{
|
||||
width: `${o.percent}%`,
|
||||
background: "var(--accent)",
|
||||
}}
|
||||
className="j13-poll-option-bar"
|
||||
style={{ width: `${o.percent}%` }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex items-center justify-between gap-3">
|
||||
<span className="text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
<span className="j13-poll-option-row">
|
||||
<span className="j13-poll-option-text">
|
||||
{state.can_vote ? (checked ? "✓ " : "") : ""}
|
||||
{o.text}
|
||||
</span>
|
||||
{showResults && (
|
||||
<span className="meta tabular-nums text-[12.5px] shrink-0">
|
||||
<span className="j13-poll-option-stat tabular-nums">
|
||||
{o.votes} · {o.percent}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{namedVoters.length > 0 && (
|
||||
<ul className="mt-1.5 flex flex-wrap gap-1.5 px-1">
|
||||
<ul className="j13-poll-voters">
|
||||
{namedVoters.map((v) => (
|
||||
<li key={v.id}>
|
||||
<Link
|
||||
href={`/u/${v.id}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[12px]"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Link href={`/u/${v.id}`} className="j13-poll-voter">
|
||||
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
|
||||
{v.nickname || v.username}
|
||||
</Link>
|
||||
@@ -253,106 +426,121 @@ function PollPanel({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{state.can_vote && (
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void vote()}>
|
||||
提交投票
|
||||
</button>
|
||||
)}
|
||||
{!loggedIn && !state.closed && (
|
||||
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
|
||||
登录后投票
|
||||
</Link>
|
||||
)}
|
||||
{state.can_close && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void close()}
|
||||
>
|
||||
结束投票
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(state.can_vote || (!loggedIn && !state.closed)) && (
|
||||
<div className="j13-typed-actions-row">
|
||||
{state.can_vote && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void vote()}
|
||||
>
|
||||
提交投票
|
||||
</button>
|
||||
)}
|
||||
{!loggedIn && !state.closed && (
|
||||
<Link
|
||||
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
|
||||
className="btn btn-line btn-sm"
|
||||
>
|
||||
登录后投票
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="j13-typed-foot">
|
||||
<span className="j13-typed-foot-status">
|
||||
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
|
||||
{footLeft}
|
||||
</span>
|
||||
<TypedFootTime
|
||||
ended={state.closed}
|
||||
endsAt={state.ends_at}
|
||||
endedAt={state.closed_at}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BountyPanel({ postId, state }: { postId: number; state: BountyState }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
function BountyPanel({ state }: { postId: number; state: BountyState }) {
|
||||
const statusBadge = state.accepted_comment_id
|
||||
? { cls: "badge-status badge-status-solved", label: "已采纳" }
|
||||
: state.expired || state.refunded
|
||||
? { cls: "badge-status badge-status-closed", label: "已过期" }
|
||||
: { cls: "badge-status badge-status-open", label: "进行中" };
|
||||
|
||||
const refund = async () => {
|
||||
if (!confirming) {
|
||||
setConfirming(true);
|
||||
window.setTimeout(() => setConfirming(false), 3000);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiRefundBounty(String(postId));
|
||||
if (res.post) {
|
||||
toast("悬赏已退回", "ok");
|
||||
router.refresh();
|
||||
} else toast(res.error || "退回失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
const footLeft = state.accepted_comment_id
|
||||
? "悬赏已完成"
|
||||
: state.expired || state.refunded
|
||||
? state.can_accept
|
||||
? "已过期退回 · 仍可采纳支付"
|
||||
: "悬赏已过期退回"
|
||||
: "悬赏进行中";
|
||||
|
||||
const statusText = state.expired
|
||||
? "已过期,积分已自动退回"
|
||||
: state.refunded
|
||||
? "已退回给发帖人"
|
||||
: state.accepted_comment_id
|
||||
? null
|
||||
const bodyHint = state.accepted_answer
|
||||
? "悬赏积分已发放给采纳答案"
|
||||
: state.accepted_comment_id
|
||||
? null
|
||||
: state.expired || state.refunded
|
||||
? state.can_accept
|
||||
? "到期未采纳已自动退回;采纳将从余额扣除,支付后不可撤回"
|
||||
: "积分已自动退回发帖人"
|
||||
: state.escrowed
|
||||
? "积分已托管,采纳优质回答后发放"
|
||||
? "积分已托管,采纳优质回答后发放;到期未采纳自动退回"
|
||||
: "悬赏未托管";
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
悬赏 {state.points} 积分
|
||||
<section className="j13-interact-card j13-typed-card" aria-label="悬赏">
|
||||
<div className="j13-typed-head">
|
||||
<div className="j13-typed-head-main">
|
||||
<h2 className="j13-typed-title is-bounty">
|
||||
<Coins size={16} strokeWidth={2.25} aria-hidden />
|
||||
悬赏
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
{state.accepted_answer
|
||||
? "悬赏积分已发放给采纳答案"
|
||||
: state.accepted_comment_id
|
||||
? (
|
||||
<>
|
||||
已采纳{" "}
|
||||
<a
|
||||
href={`#comment-${state.accepted_comment_id}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
#{state.accepted_floor || "?"}
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
: statusText}
|
||||
{state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
|
||||
</p>
|
||||
<span className={statusBadge.cls}>{statusBadge.label}</span>
|
||||
</div>
|
||||
{state.can_refund && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${confirming ? "btn-primary" : "btn-line"}`}
|
||||
disabled={busy}
|
||||
onClick={() => void refund()}
|
||||
</div>
|
||||
|
||||
<p className="j13-typed-meta">
|
||||
<span className="j13-bounty-points tabular-nums">{state.points} 积分</span>
|
||||
{bodyHint ? ` · ${bodyHint}` : null}
|
||||
{state.can_accept ? " · 在评论旁点击「采纳」发放积分" : null}
|
||||
</p>
|
||||
|
||||
{!state.accepted_comment_id && state.ends_at ? (
|
||||
<DeadlineCountdown
|
||||
endsAt={state.ends_at}
|
||||
expiredLabel="已到截止时间"
|
||||
expiredHint={state.can_accept ? "已自动退回,仍可采纳并支付" : "等待处理"}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.accepted_comment_id > 0 && (
|
||||
<p className="j13-typed-meta">
|
||||
已采纳{" "}
|
||||
<a
|
||||
href={`#comment-${state.accepted_comment_id}`}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{confirming ? "再点确认退回" : "退回悬赏"}
|
||||
</button>
|
||||
)}
|
||||
{state.can_accept && (
|
||||
<p className="meta text-[12.5px]">在评论旁点击「采纳」发放积分</p>
|
||||
)}
|
||||
#{state.accepted_floor || "?"}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="j13-typed-foot">
|
||||
<span className="j13-typed-foot-status">
|
||||
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
|
||||
{footLeft}
|
||||
</span>
|
||||
<TypedFootTime
|
||||
ended={!!state.accepted_comment_id || state.expired || state.refunded}
|
||||
endsAt={state.ends_at}
|
||||
endedAt={state.ends_at}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -381,35 +569,28 @@ function LotteryPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const prizes = state.prizes?.length
|
||||
? state.prizes
|
||||
: state.slots > 0
|
||||
? [{ name: "奖品", count: state.slots }]
|
||||
: [];
|
||||
|
||||
const statusBadge = state.drawn
|
||||
? { cls: "badge-status badge-status-closed", label: "已开奖" }
|
||||
: { cls: "badge-status badge-status-open", label: "进行中" };
|
||||
|
||||
return (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
抽奖 · {state.slots} 个名额
|
||||
<section className="j13-interact-card j13-typed-card" aria-label="抽奖">
|
||||
<div className="j13-typed-head">
|
||||
<div className="j13-typed-head-main">
|
||||
<h2 className="j13-typed-title is-lottery">
|
||||
<Gift size={16} strokeWidth={2.25} aria-hidden />
|
||||
抽奖
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
已有 {state.entry_count} 名回帖用户参与
|
||||
{state.drawn ? " · 已开奖" : state.closed ? " · 已截止" : ""}
|
||||
{state.eligible && !state.drawn ? " · 你已在奖池中" : ""}
|
||||
{state.ends_at && !state.drawn ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
|
||||
</p>
|
||||
{!state.drawn && (
|
||||
<p className="meta text-[12px] mt-1">对本帖发表评论即可参与(楼主除外)</p>
|
||||
)}
|
||||
<span className={statusBadge.cls}>{statusBadge.label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.can_close && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiCloseLottery(String(postId)), "已截止")}
|
||||
>
|
||||
提前截止
|
||||
</button>
|
||||
)}
|
||||
{state.can_draw && (
|
||||
{state.can_draw && (
|
||||
<div className="j13-typed-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
@@ -418,38 +599,83 @@ function LotteryPanel({
|
||||
>
|
||||
开奖
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(state.can_draw || (state.eligible && !state.drawn)) && (
|
||||
<p className="j13-typed-meta">
|
||||
{state.can_draw ? `已有 ${state.entry_count} 名主评用户入池` : null}
|
||||
{state.can_draw && state.eligible && !state.drawn ? " · " : null}
|
||||
{state.eligible && !state.drawn ? "你已在奖池中" : null}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!state.drawn && state.ends_at ? (
|
||||
<DeadlineCountdown
|
||||
endsAt={state.ends_at}
|
||||
expiredLabel="已到截止时间"
|
||||
expiredHint="等待开奖"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{prizes.length > 0 && (
|
||||
<ul className="j13-lottery-prize-row" aria-label="奖品">
|
||||
{prizes.map((prize, i) => (
|
||||
<li key={`${prize.name}-${i}`}>
|
||||
<div className="j13-lottery-prize-chip" title={`${prize.name} ×${prize.count}`}>
|
||||
<Trophy size={13} strokeWidth={2.25} aria-hidden />
|
||||
<span className="j13-lottery-prize-chip-text">
|
||||
{prize.name}
|
||||
<span className="j13-lottery-prize-chip-count">×{prize.count}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{state.drawn && (
|
||||
<div>
|
||||
<p className="font-semibold text-[13px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
{state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"}
|
||||
</p>
|
||||
{state.winners.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-3">
|
||||
<>
|
||||
<p className="j13-lottery-winners-label">中奖名单</p>
|
||||
{state.winners.length > 0 ? (
|
||||
<ul className="j13-lottery-winner-row">
|
||||
{state.winners.map((w) => (
|
||||
<li key={w.id}>
|
||||
<span className="badge-winner inline-flex items-center gap-2 !py-1.5 !px-2.5 !text-[13px] !font-medium">
|
||||
<Link href={`/u/${w.id}`} className="inline-flex items-center gap-2 min-w-0">
|
||||
<Avatar name={w.nickname || w.username} src={w.avatar || undefined} size={22} />
|
||||
<span className="truncate">{w.nickname || w.username}</span>
|
||||
</Link>
|
||||
{w.comment_id && w.floor ? (
|
||||
<a
|
||||
href={`#comment-${w.comment_id}`}
|
||||
className="opacity-90 hover:underline shrink-0"
|
||||
>
|
||||
#{w.floor}
|
||||
</a>
|
||||
) : null}
|
||||
</span>
|
||||
<li key={`${w.id}-${w.comment_id || 0}`}>
|
||||
<div className="j13-lottery-winner">
|
||||
<Avatar
|
||||
name={w.nickname || w.username}
|
||||
src={w.avatar || undefined}
|
||||
size={36}
|
||||
className="j13-lottery-winner-avatar"
|
||||
/>
|
||||
<div className="j13-lottery-winner-body">
|
||||
<Link href={`/u/${w.id}`} className="j13-lottery-winner-name">
|
||||
{w.nickname || w.username}
|
||||
</Link>
|
||||
{w.prize_name ? (
|
||||
<span className="j13-lottery-winner-prize">{w.prize_name}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="j13-typed-empty">无人回帖,开奖结果为空</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="j13-typed-foot">
|
||||
<span className="j13-typed-foot-status">
|
||||
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
|
||||
{state.drawn ? "开奖完成" : "抽奖进行中"}
|
||||
</span>
|
||||
{state.ends_at ? (
|
||||
<span className="j13-typed-foot-time">截止时间: {formatEndsAt(state.ends_at)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ export interface QuestionState {
|
||||
accepted_comment_id: number;
|
||||
accepted_floor: number;
|
||||
accepted_answer?: AcceptedAnswer | null;
|
||||
solved_at?: string;
|
||||
can_accept: boolean;
|
||||
can_solve: boolean;
|
||||
can_reopen: boolean;
|
||||
@@ -279,6 +280,8 @@ export interface PollState {
|
||||
multi: boolean;
|
||||
closed: boolean;
|
||||
anonymous: boolean;
|
||||
ends_at?: string;
|
||||
closed_at?: string;
|
||||
total_votes: number;
|
||||
voters: number;
|
||||
my_options: number[];
|
||||
@@ -298,7 +301,11 @@ export interface BountyState {
|
||||
ends_at?: string;
|
||||
settled: boolean;
|
||||
can_accept: boolean;
|
||||
can_refund: boolean;
|
||||
}
|
||||
|
||||
export interface LotteryPrize {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface LotteryEntrant {
|
||||
@@ -309,17 +316,21 @@ export interface LotteryEntrant {
|
||||
/** 中奖楼层评论 ID(该用户最早主评,每人仅一条) */
|
||||
comment_id?: number;
|
||||
floor?: number;
|
||||
prize_name?: string;
|
||||
}
|
||||
|
||||
export interface LotteryState {
|
||||
slots: number;
|
||||
prizes: LotteryPrize[];
|
||||
drawn: boolean;
|
||||
closed: boolean;
|
||||
ends_at?: string;
|
||||
entry_count: number;
|
||||
eligible?: boolean;
|
||||
/** 新号入池冷静期小时数(与发帖冷静期同源,0=关闭) */
|
||||
cooldown_hours?: number;
|
||||
can_draw: boolean;
|
||||
can_close: boolean;
|
||||
can_edit_prizes?: boolean;
|
||||
winners: LotteryEntrant[];
|
||||
}
|
||||
|
||||
@@ -938,14 +949,6 @@ export async function apiAcceptBounty(postId: string, commentId: number) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiRefundBounty(postId: string) {
|
||||
const res = await fetchWithRefresh(`/api/posts/${postId}/bounty/refund`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiAcceptQuestion(postId: string, commentId: number) {
|
||||
const res = await fetchWithRefresh(`/api/posts/${postId}/question/accept`, {
|
||||
method: "POST",
|
||||
@@ -971,14 +974,6 @@ export async function apiReopenQuestion(postId: string) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiCloseLottery(postId: string) {
|
||||
const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/close`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiDrawLottery(postId: string) {
|
||||
const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/draw`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -5,7 +5,7 @@ export const POST_TYPES = [
|
||||
{ value: "question", label: "问答", hint: "提问求解" },
|
||||
{ value: "poll", label: "投票", hint: "发起投票,读者可参与" },
|
||||
{ value: "bounty", label: "悬赏", hint: "发布时托管积分,采纳后发放" },
|
||||
{ value: "lottery", label: "抽奖", hint: "回帖即参与,截止自动开奖" },
|
||||
{ value: "lottery", label: "抽奖", hint: "奖品类型+数量入池,评论后锁定奖品" },
|
||||
] as const;
|
||||
|
||||
export type PostTypeValue = (typeof POST_TYPES)[number]["value"];
|
||||
@@ -21,6 +21,7 @@ export type ContentAccessValue = (typeof CONTENT_ACCESS)[number]["value"];
|
||||
|
||||
export const BOUNTY_EXPIRE_DAYS = [3, 7, 14, 30] as const;
|
||||
export const LOTTERY_EXPIRE_DAYS = [1, 3, 7, 14] as const;
|
||||
export const POLL_EXPIRE_DAYS = [1, 3, 7, 14] as const;
|
||||
|
||||
export function normalizePostType(t?: string): PostTypeValue {
|
||||
if (!t) return "discussion";
|
||||
@@ -74,12 +75,13 @@ export function typeStatusClass(postType?: string, typeStatus?: string): string
|
||||
break;
|
||||
case "bounty":
|
||||
if (s === "solved") kind = "solved";
|
||||
else if (s === "expired") kind = "expired";
|
||||
// 已过期:与投票「已结束」同色(灰胶囊,标识已到期)
|
||||
else if (s === "expired") kind = "closed";
|
||||
else kind = "open";
|
||||
break;
|
||||
case "lottery":
|
||||
if (s === "drawn") kind = "drawn";
|
||||
else if (s === "closed") kind = "closed";
|
||||
// 已开奖 / 已截止:与投票「已结束」同色(灰胶囊)
|
||||
if (s === "drawn" || s === "closed") kind = "closed";
|
||||
else kind = "open";
|
||||
break;
|
||||
default:
|
||||
|
||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Reference in New Issue
Block a user