Skip to content

Commit 28f0f60

Browse files
committed
v 1.0.7.2 添加修改文章发布内容
1 parent a3642ed commit 28f0f60

14 files changed

Lines changed: 244 additions & 28 deletions

File tree

frontend/src/api/admin.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ export function publishArticle(id) {
4747
return request(`/api/v1/articles/${id}/publish`, { method: 'POST', withAuth: true })
4848
}
4949

50+
export function publishArticleWithTime(id, payload) {
51+
return request(`/api/v1/articles/${id}/publish`, {
52+
method: 'POST',
53+
withAuth: true,
54+
body: JSON.stringify(payload || {})
55+
})
56+
}
57+
5058
export function deleteMyArticle(id) {
5159
return request(`/api/v1/articles/${id}`, { method: 'DELETE', withAuth: true })
5260
}

frontend/src/api/http.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* - 未设置:开发环境默认连仓库里的远程后端;生产环境默认同域(与上面空字符串一致)。
55
* 注意:不要用 `||`,否则空字符串会被当成假值而误用默认 IP。
66
*/
7+
import { clearAuth, getStoredAccessToken } from '../auth/session'
8+
79
function resolveApiBase() {
810
const v = import.meta.env.VITE_API_BASE
911
if (v === '') return ''
@@ -28,7 +30,7 @@ export async function request(path, options = {}) {
2830
}
2931

3032
if (options.withAuth) {
31-
const token = localStorage.getItem('accessToken')
33+
const token = getStoredAccessToken()
3234
if (!token) {
3335
const err = new Error('未登录')
3436
err.httpStatus = 401
@@ -50,6 +52,17 @@ export async function request(path, options = {}) {
5052
// ignore
5153
}
5254

55+
if (res.status === 401 && options.withAuth) {
56+
clearAuth()
57+
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/console')) {
58+
const redirect = window.location.pathname + window.location.search
59+
window.location.assign('/console/login?redirect=' + encodeURIComponent(redirect))
60+
}
61+
const err = new Error('登录已失效')
62+
err.httpStatus = 401
63+
throw err
64+
}
65+
5366
// API 统一返回 ApiResponse,HTTP 层可能仍为 200
5467
if (json && typeof json.code === 'number' && json.code !== 0) {
5568
const msg = json.message || '请求失败'

frontend/src/api/media.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { getStoredAccessToken } from '../auth/session'
12
import { API_BASE } from './http'
23

34
function getAuthHeader() {
4-
const token = localStorage.getItem('accessToken')
5+
const token = getStoredAccessToken()
56
if (!token) return {}
67
return { Authorization: `Bearer ${token}` }
78
}

frontend/src/api/profile.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { getStoredAccessToken } from '../auth/session'
12
import { request } from './http'
23

34
export function fetchPublicProfile() {
45
// 已登录时也希望前台右侧头像/昵称/签名随当前用户更新
5-
const token = localStorage.getItem('accessToken')
6+
const token = getStoredAccessToken()
67
if (token) {
78
return request(`/api/v1/profile`, { withAuth: true })
89
}

frontend/src/assets/blog.css

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -922,8 +922,45 @@ body {
922922
width: 100%;
923923
display: flex;
924924
align-items: center;
925-
justify-content: flex-end;
925+
justify-content: space-between;
926926
min-height: 40px;
927+
gap: 10px;
928+
}
929+
930+
.site-top-actions {
931+
display: inline-flex;
932+
align-items: center;
933+
gap: 10px;
934+
}
935+
936+
.top-action-btn {
937+
display: inline-flex;
938+
align-items: center;
939+
gap: 8px;
940+
border-radius: 999px;
941+
padding: 8px 14px;
942+
cursor: pointer;
943+
font-weight: 700;
944+
font-size: 13px;
945+
border: 1px solid var(--border);
946+
background: var(--card);
947+
color: var(--text);
948+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
949+
}
950+
951+
.top-action-ico {
952+
display: inline-flex;
953+
align-items: center;
954+
justify-content: center;
955+
opacity: 0.9;
956+
}
957+
958+
.top-action-btn:hover {
959+
border-color: rgba(170, 59, 255, 0.45);
960+
}
961+
962+
[data-theme='dark'] .top-action-btn {
963+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
927964
}
928965

929966
.theme-toggle {

frontend/src/auth/session.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* 控制台访问:仅依赖登录态(JWT),不依赖「是否先访问过前台」等弱门槛。
3+
*/
4+
5+
function b64UrlDecode(segment) {
6+
let b64 = segment.replace(/-/g, '+').replace(/_/g, '/')
7+
while (b64.length % 4) b64 += '='
8+
return atob(b64)
9+
}
10+
11+
export function getStoredAccessToken() {
12+
const t = localStorage.getItem('accessToken')
13+
if (typeof t !== 'string') return null
14+
const s = t.trim()
15+
return s.length > 0 ? s : null
16+
}
17+
18+
/** 是否为标准 JWT 外形(header.payload.sig) */
19+
export function isLikelyJwt(token) {
20+
return typeof token === 'string' && token.split('.').length === 3
21+
}
22+
23+
export function parseJwtPayload(token) {
24+
if (!isLikelyJwt(token)) return null
25+
try {
26+
const json = b64UrlDecode(token.split('.')[1])
27+
return JSON.parse(json)
28+
} catch {
29+
return null
30+
}
31+
}
32+
33+
export function isJwtExpired(token) {
34+
const payload = parseJwtPayload(token)
35+
if (!payload) return true
36+
if (typeof payload.exp !== 'number') return false
37+
return Date.now() >= payload.exp * 1000
38+
}
39+
40+
export function clearAuth() {
41+
localStorage.removeItem('accessToken')
42+
localStorage.removeItem('refreshToken')
43+
}
44+
45+
export function isConsoleSessionValid() {
46+
const token = getStoredAccessToken()
47+
if (!token || !isLikelyJwt(token) || isJwtExpired(token)) return false
48+
return true
49+
}

frontend/src/components/BlogHeader.vue

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
<template>
22
<header class="site-top-bar" role="banner">
33
<div class="site-top-bar-inner">
4+
<div class="site-top-actions">
5+
<button type="button" class="top-action-btn" aria-label="面试内容" @click="onInterviewClick">
6+
<span class="top-action-ico" aria-hidden="true">
7+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
8+
<path d="M9 2h6v3H9z" />
9+
<path d="M7 5h10a2 2 0 0 1 2 2v12a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V7a2 2 0 0 1 2-2z" />
10+
<path d="M9 11h6M9 15h6" />
11+
</svg>
12+
</span>
13+
面试内容
14+
</button>
15+
</div>
416
<button
517
type="button"
618
class="theme-toggle"
@@ -17,6 +29,7 @@
1729
<script setup>
1830
import { onMounted, ref } from 'vue'
1931
import { toggleTheme as applyToggle } from '../theme'
32+
import { showMessage } from '../utils/message'
2033
2134
const isDark = ref(false)
2235
@@ -32,4 +45,8 @@ function onToggle() {
3245
applyToggle()
3346
sync()
3447
}
48+
49+
function onInterviewClick() {
50+
showMessage('功能暂未开放')
51+
}
3552
</script>

frontend/src/layouts/ConsoleLayout.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
import { computed, onMounted, ref } from 'vue'
138138
import { useRoute, useRouter } from 'vue-router'
139139
import { fetchMe } from '../api/admin'
140+
import { clearAuth } from '../auth/session'
140141
141142
const router = useRouter()
142143
const route = useRoute()
@@ -171,8 +172,7 @@ async function loadMe() {
171172
}
172173
173174
function logout() {
174-
localStorage.removeItem('accessToken')
175-
localStorage.removeItem('refreshToken')
175+
clearAuth()
176176
router.push('/console/login')
177177
}
178178

frontend/src/router/index.js

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { createRouter, createWebHistory } from 'vue-router'
22

3+
import { clearAuth, isConsoleSessionValid } from '../auth/session'
4+
35
import HomeView from '../views/HomeView.vue'
46
import ArticleDetailView from '../views/ArticleDetailView.vue'
57
import AllArticlesView from '../views/AllArticlesView.vue'
@@ -76,26 +78,31 @@ const router = createRouter({
7678
routes
7779
})
7880

79-
/**
80-
* 本标签页内是否已访问过「前台」任意页面(非 /console*)。
81-
* 用于避免地址栏直接敲 /console、/console/login:须先访问本站前台一次。
82-
*/
83-
const SITE_ENTRY_KEY = 'openblog_site_entry_ok'
84-
85-
router.afterEach((to) => {
86-
if (!to.path.startsWith('/console')) {
87-
sessionStorage.setItem(SITE_ENTRY_KEY, '1')
88-
}
89-
})
81+
/** 仅允许站内相对路径,防止 open redirect */
82+
function safeConsoleRedirect(raw) {
83+
if (typeof raw !== 'string') return '/console'
84+
const t = raw.trim()
85+
if (!t.startsWith('/') || t.startsWith('//')) return '/console'
86+
if (t.startsWith('/console/login')) return '/console'
87+
return t
88+
}
9089

9190
router.beforeEach((to) => {
92-
if (to.path.startsWith('/console')) {
93-
if (!sessionStorage.getItem(SITE_ENTRY_KEY)) {
94-
return { path: '/' }
95-
}
96-
if (to.path !== '/console/login' && !localStorage.getItem('accessToken')) {
97-
return { path: '/console/login', query: { redirect: to.fullPath } }
91+
if (!to.path.startsWith('/console')) return true
92+
93+
const isLoginPage = to.path === '/console/login'
94+
95+
if (isLoginPage) {
96+
if (isConsoleSessionValid()) {
97+
const r = to.query.redirect
98+
return { path: safeConsoleRedirect(typeof r === 'string' ? r : '') }
9899
}
100+
return true
101+
}
102+
103+
if (!isConsoleSessionValid()) {
104+
clearAuth()
105+
return { path: '/console/login', query: { redirect: to.fullPath } }
99106
}
100107
return true
101108
})

frontend/src/views/ConsoleArticlesView.vue

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@
7777
<textarea v-model="form.contentMarkdown" class="textarea" rows="10"></textarea>
7878
</div>
7979

80+
<div class="field" style="margin-top: 12px; max-width: 420px">
81+
<div class="label">发布时间(可选,留空为当前时间;允许早于当前时间)</div>
82+
<input v-model="publishAtInput" class="input" type="datetime-local" />
83+
</div>
84+
8085
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 14px">
8186
<button class="btn primary" @click="saveDraft">保存草稿/更新</button>
8287
<button class="btn" :disabled="!selectedId" @click="publish" :style="{ opacity: selectedId ? 1 : 0.6 }">
@@ -106,6 +111,7 @@ import {
106111
createDraft,
107112
updateArticle,
108113
publishArticle,
114+
publishArticleWithTime,
109115
deleteMyArticle
110116
} from '../api/admin'
111117
import { uploadMedia } from '../api/media'
@@ -123,6 +129,8 @@ const form = ref({
123129
coverMediaKey: null
124130
})
125131
132+
const publishAtInput = ref('')
133+
126134
const loadingArticles = ref(false)
127135
const loadingDetail = ref(false)
128136
const myArticles = ref([])
@@ -153,6 +161,26 @@ function resetEditor() {
153161
form.value.summary = ''
154162
form.value.contentMarkdown = ''
155163
form.value.coverMediaKey = null
164+
publishAtInput.value = ''
165+
}
166+
167+
function toLocalDatetimeInput(iso) {
168+
if (!iso) return ''
169+
try {
170+
const d = new Date(iso)
171+
if (Number.isNaN(d.getTime())) return ''
172+
const pad = (n) => String(n).padStart(2, '0')
173+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
174+
} catch {
175+
return ''
176+
}
177+
}
178+
179+
function toIsoOrEmpty(v) {
180+
if (!v || !String(v).trim()) return undefined
181+
const d = new Date(v)
182+
if (Number.isNaN(d.getTime())) return undefined
183+
return d.toISOString()
156184
}
157185
158186
async function newDraft() {
@@ -170,6 +198,7 @@ async function loadEditor(id) {
170198
form.value.summary = detail.summary || ''
171199
form.value.contentMarkdown = detail.contentMarkdown || ''
172200
form.value.coverMediaKey = detail.coverMediaKey || null
201+
publishAtInput.value = toLocalDatetimeInput(detail.publishedAt)
173202
} finally {
174203
loadingDetail.value = false
175204
}
@@ -216,7 +245,12 @@ async function publish() {
216245
articleError.value = ''
217246
articleSuccess.value = ''
218247
try {
219-
await publishArticle(selectedId.value)
248+
const publishedAt = toIsoOrEmpty(publishAtInput.value)
249+
if (publishedAt) {
250+
await publishArticleWithTime(selectedId.value, { publishedAt })
251+
} else {
252+
await publishArticle(selectedId.value)
253+
}
220254
await loadArticles()
221255
articleSuccess.value = '发布成功'
222256
} catch (e) {

0 commit comments

Comments
 (0)