MVP: 按 07 风格重写前端 + Go 后端落地(长文/短文、编辑器、后台管理)
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
SHELL := /bin/bash
|
||||
GO ?= go
|
||||
PNPM ?= pnpm
|
||||
ADDR ?= :8080
|
||||
|
||||
.PHONY: help deps web server dev start build test clean db-reset stop logs
|
||||
|
||||
help:
|
||||
@echo "ONE · 一个博客"
|
||||
@echo ""
|
||||
@echo " make dev 前后端一起起(后端 8080,前端 3000 带热更新)"
|
||||
@echo " make web 只构建前端到 frontend/dist"
|
||||
@echo " make server 只起后端(8080)"
|
||||
@echo " make start 构建前端 + 单端口启动(8080 同时服务前后端)"
|
||||
@echo " make deps 安装前后端依赖"
|
||||
@echo " make test 跑后端测试"
|
||||
@echo " make stop 停掉 make dev 起的两个进程"
|
||||
@echo " make logs 看 dev 进程日志"
|
||||
@echo " make clean 清理构建产物"
|
||||
@echo " make db-reset 删掉本地 SQLite 库(会清空数据)"
|
||||
|
||||
deps:
|
||||
cd backend && $(GO) mod download
|
||||
cd frontend && $(PNPM) install
|
||||
|
||||
web:
|
||||
cd frontend && $(PNPM) run build
|
||||
|
||||
server:
|
||||
cd backend && ONE_ADDR=$(ADDR) $(GO) run .
|
||||
|
||||
# 一条命令起后端 + 前端(开发用,两个进程)
|
||||
# 输出写进 .run/*.log,避免后台进程占住终端导致命令不返回
|
||||
dev:
|
||||
@mkdir -p .run
|
||||
@(cd backend && { ONE_ADDR=$(ADDR) $(GO) run . > ../.run/server.log 2>&1 & echo $$! > ../.run/server.pid; })
|
||||
@(cd frontend && { $(PNPM) run dev > ../.run/web.log 2>&1 & echo $$! > ../.run/web.pid; })
|
||||
@echo "后端 http://localhost:8080 (日志 .run/server.log)"
|
||||
@echo "前端 http://localhost:3000 (日志 .run/web.log)"
|
||||
@echo "停止:make stop 看日志:make logs"
|
||||
|
||||
# 生产式单端口:构建前端,由 Go 静态托管
|
||||
start: web
|
||||
cd backend && ONE_ADDR=$(ADDR) $(GO) run .
|
||||
|
||||
build: web
|
||||
cd backend && $(GO) build -o ../one-server .
|
||||
|
||||
test:
|
||||
cd backend && $(GO) test ./...
|
||||
|
||||
stop:
|
||||
@for f in .run/server.pid .run/web.pid; do \
|
||||
if [ -f $$f ]; then pid=$$(cat $$f); \
|
||||
pkill -P $$pid 2>/dev/null; kill $$pid 2>/dev/null; \
|
||||
fi; \
|
||||
done
|
||||
@rm -f .run/*.pid
|
||||
@echo stopped
|
||||
|
||||
logs:
|
||||
@tail -f .run/server.log .run/web.log
|
||||
|
||||
clean:
|
||||
rm -rf frontend/dist one-server .run
|
||||
|
||||
db-reset:
|
||||
rm -f data/one.db data/one.db-wal data/one.db-shm
|
||||
@@ -0,0 +1,159 @@
|
||||
# ONE · 一个博客
|
||||
|
||||
一个能跑通全流程的博客 MVP:后台登录 → 写文章(长文 / 短文)→ 发布 → 前台按「07 融合 + Twitter 信息流」风格展示。
|
||||
|
||||
- 后端:Go(`net/http` + `database/sql`,SQLite / PostgreSQL 双支持),在 `backend/`
|
||||
- 前台 + 后台:Vue 3 + Vue Router + Vite,在 `frontend/`(pnpm 管理依赖)
|
||||
- Markdown:服务端用 `goldmark` 渲染入库,编辑器实时预览用 `marked` + `DOMPurify`
|
||||
|
||||
## 一条命令跑起来
|
||||
|
||||
```bash
|
||||
make dev # 后端 8080 + 前端 3000(前端带热更新,已配好 /api 代理)
|
||||
```
|
||||
|
||||
打开 http://localhost:3000 是前台,http://localhost:3000/admin 是后台。
|
||||
停掉:`make stop`
|
||||
|
||||
想要单端口(前端构建产物由 Go 直接托管,接近生产形态):
|
||||
|
||||
```bash
|
||||
make start # 先 pnpm run build,再起 Go,只开 8080
|
||||
```
|
||||
|
||||
打开 http://localhost:8080 。
|
||||
|
||||
其它:`make web` 只构建前端、`make server` 只起后端、`make test` 跑后端测试、`make clean` 清构建产物、`make db-reset` 清空本地 SQLite 数据。
|
||||
|
||||
## 后台登录
|
||||
|
||||
默认账号密码来自环境变量,未设置时是 `admin` / `admin`(启动日志会打印出来):
|
||||
|
||||
```bash
|
||||
ONE_ADMIN_USER=admin ONE_ADMIN_PASSWORD=换一个 make start
|
||||
```
|
||||
|
||||
登录态是服务端签发的 httpOnly cookie(HMAC-SHA256,7 天有效),同时支持 `Authorization: Bearer <token>`,方便用 curl / 脚本写文章。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `ONE_ADDR` | `:8080` | 监听地址 |
|
||||
| `ONE_DB_DRIVER` | `sqlite` | `sqlite` 或 `postgres` |
|
||||
| `ONE_DB_DSN` | `./data/one.db` | 数据库连接串(Postgres 示例:`postgres://user:pass@localhost/one?sslmode=disable`) |
|
||||
| `ONE_ADMIN_USER` | `admin` | 后台用户名 |
|
||||
| `ONE_ADMIN_PASSWORD` | `admin` | 后台密码 |
|
||||
| `ONE_SECRET` | 随机生成 | 会话签名密钥;不设的话重启后登录态失效 |
|
||||
| `ONE_SITE_URL` | `http://localhost:8080` | RSS 里的站点地址,部署时务必改成真实域名 |
|
||||
| `ONE_WEB_DIST` | `./frontend/dist` | 前端构建产物目录 |
|
||||
| `ONE_DATA_DIR` | `./data` | SQLite 数据目录 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
backend/ Go 后端(模块名 oneblog)
|
||||
main.go 路由装配、静态托管 SPA、优雅退出
|
||||
internal/config 环境变量
|
||||
internal/db SQLite / PostgreSQL 连接与 `?` → `$n` 占位符重写
|
||||
internal/model 数据结构
|
||||
internal/store schema 迁移 + 全部 SQL
|
||||
internal/render goldmark 渲染、阅读时长估算、摘要截取
|
||||
internal/api 公开接口 + RSS(/api/*、/rss.xml)
|
||||
internal/admin 后台接口与登录鉴权(/api/admin/*)
|
||||
internal/httpx JSON 读写助手
|
||||
frontend/ Vue 3 前端(pnpm)
|
||||
src/styles.css 设计 token 与正文排版(07 风格)
|
||||
src/views 前台:时间线 / 详情 / 归档 / 标签 / 关于
|
||||
src/admin 后台:登录 / 列表 / 编辑器 / 标签 / 设置
|
||||
src/components 左栏导航、右栏卡片、时间线行
|
||||
```
|
||||
|
||||
原有那一版 Vue 前端(07 风格重写之前)保留在 git 历史的第一个 commit `edee708` 里,
|
||||
需要对照或回滚:`git checkout edee708 -- frontend/src`。
|
||||
|
||||
## 接口
|
||||
|
||||
公开(无需登录):
|
||||
|
||||
```
|
||||
GET /api/site 站点设置
|
||||
GET /api/posts?kind=&tag=&q=&page=&size= 已发布文章(kind: long|short)
|
||||
GET /api/posts/:slug 文章详情
|
||||
GET /api/archive 按年 → 月分组
|
||||
GET /api/tags 标签与计数
|
||||
GET /rss.xml (/feed 同) RSS 2.0
|
||||
```
|
||||
|
||||
后台(需登录):
|
||||
|
||||
```
|
||||
POST /api/admin/login {username,password} → {token}
|
||||
POST /api/admin/logout
|
||||
GET /api/admin/me
|
||||
GET /api/admin/posts?status=&kind=&q=&page=&size=
|
||||
POST /api/admin/posts 新建
|
||||
GET /api/admin/posts/:id
|
||||
PUT /api/admin/posts/:id 更新(编辑器自动保存走这里)
|
||||
DELETE /api/admin/posts/:id
|
||||
GET /api/admin/tags
|
||||
POST /api/admin/tags {name}
|
||||
PUT /api/admin/tags/:id {name}
|
||||
DELETE /api/admin/tags/:id
|
||||
GET /api/admin/settings
|
||||
PUT /api/admin/settings
|
||||
```
|
||||
|
||||
`Post` 的关键字段:`kind`(`long` / `short`)、`title`、`slug`、`summary`、`content_md`、`content_html`、`status`、`published_at`、`reading_minutes`、`tags[]`。
|
||||
短文没有可见标题,但 `title` 仍会由正文首句生成,供归档与标签列表索引。
|
||||
|
||||
## 部署
|
||||
|
||||
生产形态是「一个二进制 + 一个静态目录」:
|
||||
|
||||
```bash
|
||||
make build # 产出 ./one-server,前端已打进 web/dist
|
||||
ONE_ADDR=:8080 \
|
||||
ONE_SITE_URL=https://your.domain \
|
||||
ONE_DB_DRIVER=postgres \
|
||||
ONE_DB_DSN="postgres://user:pass@127.0.0.1/one?sslmode=disable" \
|
||||
ONE_SECRET=一串随机长字符串 \
|
||||
ONE_ADMIN_PASSWORD=强密码 \
|
||||
./one-server
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **静态资源**:Go 直接托管 `web/dist`,未知路径回落到 `index.html`,所以 `/post/xxx`、`/admin` 刷新都不会 404。
|
||||
- **反向代理**:前面挂 Nginx / Caddy 时把 `/` 反代到 `ONE_ADDR` 即可;如果只在内网监听,可以不挂。
|
||||
- **SQLite 部署**:把 `data/` 挂到持久卷;并发写很低的博客足够用(已开 WAL + busy_timeout)。
|
||||
- **PostgreSQL 部署**:改 `ONE_DB_DRIVER` 与 `ONE_DB_DSN`,schema 会在启动时自动创建,无需手动迁移。
|
||||
- **systemd 示例**:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ONE blog
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/opt/one
|
||||
Environment=ONE_ADDR=:8080 ONE_SITE_URL=https://your.domain ONE_SECRET=xxx ONE_ADMIN_PASSWORD=xxx
|
||||
ExecStart=/opt/one/one-server
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
- **备份**:SQLite 直接拷 `data/one.db`(停写更稳);Postgres 用 `pg_dump`。
|
||||
|
||||
## 已验证
|
||||
|
||||
`make start` 后:后台登录 → 写长文与短文 → 发布 → 前台时间线(长文标题 + 摘要 / 短文正文铺开)、详情、归档、标签、RSS 全部可见;
|
||||
编辑器改正文 1.5 秒后自动保存到服务端;桌面与移动宽度无横向滚动;SQLite 路径全流程跑通,PostgreSQL 的占位符重写有单元测试覆盖(本机没有 Postgres 实例,未做端到端验证)。
|
||||
|
||||
## 待确认
|
||||
|
||||
- 仓库地址:本仓库是在本地 `/Users/cjun/Code/one` 新建的 git 仓库,还没有 remote。给个地址就能推上去并绑进工作区。
|
||||
- 鉴权方式:目前是账号密码 + 签名 cookie。想改成纯 token 也可以,接口已经兼容 `Authorization: Bearer`。
|
||||
- 图片上传:MVP 未做,正文里先用图片外链。
|
||||
@@ -0,0 +1,22 @@
|
||||
module oneblog
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/yuin/goldmark v1.7.13
|
||||
modernc.org/sqlite v1.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,350 @@
|
||||
// Package admin serves the authenticated surface at /api/admin/*.
|
||||
// It is mounted separately from the public API so the two never share a
|
||||
// handler chain.
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
Sessions *Sessions
|
||||
}
|
||||
|
||||
const cookieName = "one_session"
|
||||
|
||||
func (a *API) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/admin/login", a.login)
|
||||
mux.HandleFunc("/api/admin/logout", a.logout)
|
||||
mux.HandleFunc("/api/admin/me", a.guard(a.me))
|
||||
|
||||
mux.HandleFunc("/api/admin/posts", a.guard(a.listPosts))
|
||||
mux.HandleFunc("/api/admin/posts/", a.guard(a.postByID))
|
||||
mux.HandleFunc("/api/admin/tags", a.guard(a.listTags))
|
||||
mux.HandleFunc("/api/admin/tags/", a.guard(a.tagByID))
|
||||
mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
|
||||
return mux
|
||||
}
|
||||
|
||||
// guard requires a valid session; the token may arrive as a cookie (browser)
|
||||
// or as a Bearer token (CLI / API client).
|
||||
func (a *API) guard(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearer(r)
|
||||
if token == "" {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" || !a.valid(token) {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func bearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
return strings.TrimSpace(h[7:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *API) valid(token string) bool {
|
||||
_, err := a.Sessions.Verify(token)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ---------- auth ----------
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
|
||||
return
|
||||
}
|
||||
var in loginRequest
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1
|
||||
if !userOK || !passOK {
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
token, exp := a.Sessions.Issue(a.Cfg.AdminUser)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: exp,
|
||||
MaxAge: a.Sessions.TTL(),
|
||||
})
|
||||
httpx.OK(w, map[string]any{"token": token, "expires_at": exp.UTC().Format(rfc3339)})
|
||||
}
|
||||
|
||||
const rfc3339 = "2006-01-02T15:04:05Z07:00"
|
||||
|
||||
func (a *API) logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) me(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.OK(w, map[string]any{"user": a.Cfg.AdminUser})
|
||||
}
|
||||
|
||||
// ---------- posts ----------
|
||||
|
||||
func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
o := store.ListOptions{
|
||||
Kind: httpx.QueryString(r, "kind"),
|
||||
Tag: httpx.QueryString(r, "tag"),
|
||||
Query: httpx.QueryString(r, "q"),
|
||||
Status: httpx.QueryString(r, "status"),
|
||||
Page: httpx.QueryInt(r, "page", 1),
|
||||
Size: httpx.QueryInt(r, "size", 20),
|
||||
}
|
||||
if o.Status == "" {
|
||||
o.Status = "any"
|
||||
}
|
||||
page, err := a.Store.List(o)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, page)
|
||||
case http.MethodPost:
|
||||
var in model.PostInput
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
in.Status = normalizeStatus(in.Status)
|
||||
p, err := a.Store.Create(in)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.Created(w, p)
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStatus(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case model.StatusDraft, model.StatusPublished:
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
default:
|
||||
return model.StatusDraft
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) postByID(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/posts/"), "/")
|
||||
if rest == "" {
|
||||
a.listPosts(w, r)
|
||||
return
|
||||
}
|
||||
id, err := parseInt(rest)
|
||||
if err != nil {
|
||||
httpx.BadRequest(w, "bad post id")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
p, err := a.Store.Get(id)
|
||||
writeOne(w, p, err)
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
var in model.PostInput
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if in.Status != "" {
|
||||
in.Status = normalizeStatus(in.Status)
|
||||
}
|
||||
p, err := a.Store.Update(id, in)
|
||||
writeOne(w, p, err)
|
||||
case http.MethodDelete:
|
||||
if err := a.Store.Delete(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT/DELETE required")
|
||||
}
|
||||
}
|
||||
|
||||
func writeOne(w http.ResponseWriter, p model.Post, err error) {
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, p)
|
||||
}
|
||||
|
||||
// ---------- tags ----------
|
||||
|
||||
func (a *API) listTags(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
tags, err := a.Store.ListTags()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []model.Tag{}
|
||||
}
|
||||
httpx.OK(w, map[string]any{"tags": tags})
|
||||
case http.MethodPost:
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
httpx.BadRequest(w, "name required")
|
||||
return
|
||||
}
|
||||
t, err := a.Store.CreateTag(name)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.Created(w, t)
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) tagByID(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/tags/"), "/")
|
||||
id, err := parseInt(rest)
|
||||
if err != nil {
|
||||
httpx.BadRequest(w, "bad tag id")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
t, err := a.Store.RenameTag(id, in.Name)
|
||||
writeTag(w, t, err)
|
||||
case http.MethodDelete:
|
||||
if err := a.Store.DeleteTag(id); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTag(w http.ResponseWriter, t model.Tag, err error) {
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(w, t)
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
|
||||
func (a *API) settings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, st)
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var in model.Settings
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if err := a.Store.UpdateSettings(in); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, st)
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT required")
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt(s string) (int64, error) {
|
||||
if s == "" {
|
||||
return 0, errors.New("empty")
|
||||
}
|
||||
var n int64
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, errors.New("not a number")
|
||||
}
|
||||
n = n*10 + int64(c-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sessions are stateless: base64("user:expiryUnix") + "." + HMAC-SHA256.
|
||||
// They survive restarts as long as ONE_SECRET stays the same.
|
||||
type Sessions struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewSessions(secret string, ttl time.Duration) *Sessions {
|
||||
if ttl <= 0 {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
return &Sessions{secret: []byte(secret), ttl: ttl}
|
||||
}
|
||||
|
||||
var ErrBadSession = errors.New("invalid session")
|
||||
|
||||
func (s *Sessions) Issue(user string) (string, time.Time) {
|
||||
exp := time.Now().Add(s.ttl)
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(user + ":" + strconv.FormatInt(exp.Unix(), 10)))
|
||||
return payload + "." + s.sign(payload), exp
|
||||
}
|
||||
|
||||
func (s *Sessions) Verify(token string) (string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return "", ErrBadSession
|
||||
}
|
||||
if !hmac.Equal([]byte(s.sign(parts[0])), []byte(parts[1])) {
|
||||
return "", ErrBadSession
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", ErrBadSession
|
||||
}
|
||||
i := strings.LastIndex(string(raw), ":")
|
||||
if i <= 0 {
|
||||
return "", ErrBadSession
|
||||
}
|
||||
user := string(raw)[:i]
|
||||
expUnix, err := strconv.ParseInt(string(raw)[i+1:], 10, 64)
|
||||
if err != nil {
|
||||
return "", ErrBadSession
|
||||
}
|
||||
if time.Now().After(time.Unix(expUnix, 0)) {
|
||||
return "", ErrBadSession
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Sessions) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Sessions) TTL() int { return int(s.ttl.Seconds()) }
|
||||
|
||||
func (s *Sessions) String() string { return fmt.Sprintf("sessions(ttl=%s)", s.ttl) }
|
||||
@@ -0,0 +1,252 @@
|
||||
// Package api serves the read-only public surface: site settings, posts,
|
||||
// archive, tags and RSS. Nothing here requires authentication.
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
func (a *API) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver})
|
||||
})
|
||||
mux.HandleFunc("/api/site", a.site)
|
||||
mux.HandleFunc("/api/posts", a.listPosts)
|
||||
mux.HandleFunc("/api/posts/", a.getPost)
|
||||
mux.HandleFunc("/api/archive", a.archive)
|
||||
mux.HandleFunc("/api/tags", a.tags)
|
||||
return mux
|
||||
}
|
||||
|
||||
// Mount registers the feed routes on the root mux, where they are not
|
||||
// shadowed by the SPA catch-all.
|
||||
func (a *API) Mount(root *http.ServeMux) {
|
||||
root.Handle("/api/", a.Routes())
|
||||
root.HandleFunc("/rss.xml", a.RSS)
|
||||
root.HandleFunc("/feed", a.RSS)
|
||||
root.HandleFunc("/feed.xml", a.RSS)
|
||||
}
|
||||
|
||||
func (a *API) site(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, st)
|
||||
}
|
||||
|
||||
func listOptions(r *http.Request, defSize int) store.ListOptions {
|
||||
return store.ListOptions{
|
||||
Kind: httpx.QueryString(r, "kind"),
|
||||
Tag: httpx.QueryString(r, "tag"),
|
||||
Query: httpx.QueryString(r, "q"),
|
||||
Page: httpx.QueryInt(r, "page", 1),
|
||||
Size: httpx.QueryInt(r, "size", defSize),
|
||||
Status: model.StatusPublished,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
|
||||
page, err := a.Store.List(listOptions(r, 10))
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, page)
|
||||
}
|
||||
|
||||
func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimPrefix(r.URL.Path, "/api/posts/")
|
||||
slug = strings.TrimSuffix(slug, "/")
|
||||
if slug == "" {
|
||||
a.listPosts(w, r)
|
||||
return
|
||||
}
|
||||
p, err := a.Store.GetBySlug(slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if p.Status != model.StatusPublished {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, p)
|
||||
}
|
||||
|
||||
func (a *API) archive(w http.ResponseWriter, r *http.Request) {
|
||||
years, err := a.Store.Archive()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"years": years})
|
||||
}
|
||||
|
||||
func (a *API) tags(w http.ResponseWriter, r *http.Request) {
|
||||
tags, err := a.Store.ListTags()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []model.Tag{}
|
||||
}
|
||||
httpx.OK(w, map[string]any{"tags": tags})
|
||||
}
|
||||
|
||||
// ---------- RSS ----------
|
||||
|
||||
type rssItem struct {
|
||||
XMLName xml.Name `xml:"item"`
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
GUID string `xml:"guid"`
|
||||
Category []string `xml:"category,omitempty"`
|
||||
}
|
||||
|
||||
type rssChannel struct {
|
||||
XMLName xml.Name `xml:"channel"`
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
Language string `xml:"language"`
|
||||
LastBuild string `xml:"lastBuildDate"`
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
|
||||
type rssFeed struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Channel rssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
// RSS renders the site feed.
|
||||
func (a *API) RSS(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
page, err := a.Store.List(store.ListOptions{Status: model.StatusPublished, Page: 1, Size: 30})
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
base := strings.TrimSuffix(a.Cfg.SiteURL, "/")
|
||||
|
||||
feed := rssFeed{
|
||||
Version: "2.0",
|
||||
Channel: rssChannel{
|
||||
Title: orDefault(settings.SiteTitle, "ONE"),
|
||||
Link: base + "/",
|
||||
Description: orDefault(settings.SiteDesc, ""),
|
||||
Language: "zh-CN",
|
||||
LastBuild: time.Now().UTC().Format(time.RFC1123Z),
|
||||
Items: []rssItem{},
|
||||
},
|
||||
}
|
||||
for _, p := range page.Items {
|
||||
title := p.Title
|
||||
if p.Kind == model.KindShort || title == "" {
|
||||
title = shortTitle(p)
|
||||
}
|
||||
desc := p.Summary
|
||||
if desc == "" {
|
||||
desc = trimRunes(stripTags(p.ContentHTML), 160)
|
||||
}
|
||||
item := rssItem{
|
||||
Title: title,
|
||||
Link: base + "/post/" + p.Slug,
|
||||
Description: desc,
|
||||
PubDate: rssDate(p.PublishedAt),
|
||||
GUID: base + "/post/" + p.Slug,
|
||||
Category: p.Tags,
|
||||
}
|
||||
if p.Kind == model.KindShort {
|
||||
item.Title = "[短] " + title
|
||||
}
|
||||
feed.Channel.Items = append(feed.Channel.Items, item)
|
||||
}
|
||||
|
||||
out, err := xml.MarshalIndent(feed, "", " ")
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(xml.Header))
|
||||
_, _ = w.Write(out)
|
||||
_, _ = w.Write([]byte("\n"))
|
||||
}
|
||||
|
||||
func shortTitle(p model.Post) string {
|
||||
s := strings.TrimSpace(stripTags(p.ContentHTML))
|
||||
if s == "" {
|
||||
return "无题"
|
||||
}
|
||||
return trimRunes(s, 40)
|
||||
}
|
||||
|
||||
func stripTags(s string) string {
|
||||
var b strings.Builder
|
||||
in := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '<':
|
||||
in = true
|
||||
case r == '>':
|
||||
in = false
|
||||
case !in:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return html.UnescapeString(strings.TrimSpace(b.String()))
|
||||
}
|
||||
|
||||
func trimRunes(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + "…"
|
||||
}
|
||||
|
||||
func rssDate(rfc3339 string) string {
|
||||
t, err := time.Parse(time.RFC3339, rfc3339)
|
||||
if err != nil {
|
||||
return time.Now().UTC().Format(time.RFC1123Z)
|
||||
}
|
||||
return t.UTC().Format(time.RFC1123Z)
|
||||
}
|
||||
|
||||
func orDefault(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
Driver string // sqlite | postgres
|
||||
DSN string
|
||||
AdminUser string
|
||||
AdminPass string
|
||||
SessionSec string
|
||||
WebDist string
|
||||
DataDir string
|
||||
SiteURL string
|
||||
InsecureDev bool
|
||||
}
|
||||
|
||||
func getenv(k, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
root := getenv("ONE_ROOT", "")
|
||||
if root == "" {
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
root = filepath.Dir(wd) // server/ -> repo root
|
||||
} else {
|
||||
root = "."
|
||||
}
|
||||
}
|
||||
|
||||
c := &Config{
|
||||
Addr: getenv("ONE_ADDR", ":8080"),
|
||||
Driver: strings.ToLower(getenv("ONE_DB_DRIVER", "sqlite")),
|
||||
AdminUser: getenv("ONE_ADMIN_USER", "admin"),
|
||||
AdminPass: getenv("ONE_ADMIN_PASSWORD", "admin"),
|
||||
SessionSec: getenv("ONE_SECRET", ""),
|
||||
WebDist: getenv("ONE_WEB_DIST", filepath.Join(root, "frontend", "dist")),
|
||||
DataDir: getenv("ONE_DATA_DIR", filepath.Join(root, "data")),
|
||||
SiteURL: getenv("ONE_SITE_URL", "http://localhost:8080"),
|
||||
}
|
||||
|
||||
if c.Driver == "" {
|
||||
c.Driver = "sqlite"
|
||||
}
|
||||
if c.Driver != "sqlite" && c.Driver != "postgres" && c.Driver != "postgresql" {
|
||||
return nil, &badDriver{c.Driver}
|
||||
}
|
||||
if c.Driver == "postgresql" {
|
||||
c.Driver = "postgres"
|
||||
}
|
||||
|
||||
if c.DSN = getenv("ONE_DB_DSN", ""); c.DSN == "" {
|
||||
if c.Driver == "sqlite" {
|
||||
c.DSN = filepath.Join(c.DataDir, "one.db")
|
||||
} else {
|
||||
c.DSN = "postgres://localhost/one?sslmode=disable"
|
||||
}
|
||||
}
|
||||
|
||||
if c.SessionSec == "" {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SessionSec = hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
c.InsecureDev = os.Getenv("ONE_ADMIN_PASSWORD") == ""
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type badDriver struct{ d string }
|
||||
|
||||
func (e *badDriver) Error() string {
|
||||
return "unsupported ONE_DB_DRIVER: " + e.d + " (use sqlite or postgres)"
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package db opens a *sql.DB for either SQLite or PostgreSQL and rewrites
|
||||
// the shared `?` placeholders into PostgreSQL's `$n` form.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Dialect int
|
||||
|
||||
const (
|
||||
SQLite Dialect = iota
|
||||
Postgres
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
Dialect Dialect
|
||||
}
|
||||
|
||||
func Open(driver, dsn string) (*DB, error) {
|
||||
var d Dialect
|
||||
switch driver {
|
||||
case "sqlite":
|
||||
d = SQLite
|
||||
if err := os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dsn = addSQLiteParams(dsn)
|
||||
case "postgres":
|
||||
d = Postgres
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported driver %q", driver)
|
||||
}
|
||||
|
||||
pool, err := sql.Open(driverName(driver), dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if d == SQLite {
|
||||
// SQLite is single-writer; keep a small pool to avoid "database is locked".
|
||||
pool.SetMaxOpenConns(1)
|
||||
} else {
|
||||
pool.SetMaxOpenConns(10)
|
||||
}
|
||||
if err := pool.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("connect %s: %w", driver, err)
|
||||
}
|
||||
return &DB{DB: pool, Dialect: d}, nil
|
||||
}
|
||||
|
||||
func driverName(driver string) string {
|
||||
if driver == "postgres" {
|
||||
return "postgres"
|
||||
}
|
||||
return "sqlite"
|
||||
}
|
||||
|
||||
func addSQLiteParams(dsn string) string {
|
||||
if strings.HasPrefix(dsn, "file:") || strings.Contains(dsn, "?") {
|
||||
return dsn
|
||||
}
|
||||
return dsn + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
||||
}
|
||||
|
||||
// Rebind converts `?` placeholders to `$1..$n` on PostgreSQL.
|
||||
func (d *DB) Rebind(q string) string {
|
||||
if d.Dialect != Postgres {
|
||||
return q
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(q) + 8)
|
||||
n := 0
|
||||
for _, r := range q {
|
||||
if r == '?' {
|
||||
n++
|
||||
b.WriteString("$")
|
||||
b.WriteString(fmt.Sprint(n))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (d *DB) Q(q string) string { return d.Rebind(q) }
|
||||
|
||||
// AutoInc returns the column definition for an auto-incrementing primary key.
|
||||
func (d *DB) AutoInc() string {
|
||||
if d.Dialect == Postgres {
|
||||
return "BIGSERIAL PRIMARY KEY"
|
||||
}
|
||||
return "INTEGER PRIMARY KEY AUTOINCREMENT"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRebind(t *testing.T) {
|
||||
sqlite := &DB{Dialect: SQLite}
|
||||
pg := &DB{Dialect: Postgres}
|
||||
|
||||
q := "SELECT * FROM posts WHERE kind = ? AND status = ? LIMIT ? OFFSET ?"
|
||||
|
||||
if got := sqlite.Rebind(q); got != q {
|
||||
t.Errorf("sqlite must keep ? placeholders, got %q", got)
|
||||
}
|
||||
|
||||
want := "SELECT * FROM posts WHERE kind = $1 AND status = $2 LIMIT $3 OFFSET $4"
|
||||
if got := pg.Rebind(q); got != want {
|
||||
t.Errorf("postgres rebind:\n got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoInc(t *testing.T) {
|
||||
if got := (&DB{Dialect: SQLite}).AutoInc(); got != "INTEGER PRIMARY KEY AUTOINCREMENT" {
|
||||
t.Errorf("sqlite autoincrement = %q", got)
|
||||
}
|
||||
if got := (&DB{Dialect: Postgres}).AutoInc(); got != "BIGSERIAL PRIMARY KEY" {
|
||||
t.Errorf("postgres autoincrement = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package httpx holds the tiny JSON helpers shared by the public and admin APIs.
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func OK(w http.ResponseWriter, v any) { WriteJSON(w, http.StatusOK, v) }
|
||||
func Created(w http.ResponseWriter, v any) { WriteJSON(w, http.StatusCreated, v) }
|
||||
|
||||
func Error(w http.ResponseWriter, status int, msg string) {
|
||||
WriteJSON(w, status, ErrorResponse{Error: msg})
|
||||
}
|
||||
|
||||
func BadRequest(w http.ResponseWriter, msg string) { Error(w, http.StatusBadRequest, msg) }
|
||||
func NotFound(w http.ResponseWriter) { Error(w, http.StatusNotFound, "not found") }
|
||||
func Unauthorized(w http.ResponseWriter) { Error(w, http.StatusUnauthorized, "unauthorized") }
|
||||
func ServerError(w http.ResponseWriter, err error) {
|
||||
Error(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
func Decode(r *http.Request, dst any) error {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return io.EOF
|
||||
}
|
||||
return json.Unmarshal(body, dst)
|
||||
}
|
||||
|
||||
func QueryInt(r *http.Request, key string, def int) int {
|
||||
s := r.URL.Query().Get(key)
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func QueryString(r *http.Request, key string) string {
|
||||
return r.URL.Query().Get(key)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package model
|
||||
|
||||
// Post kinds. "long" is a normal article; "short" is a Twitter-like note
|
||||
// with no title shown in the timeline.
|
||||
const (
|
||||
KindLong = "long"
|
||||
KindShort = "short"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusDraft = "draft"
|
||||
StatusPublished = "published"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
ContentMd string `json:"content_md,omitempty"`
|
||||
ContentHTML string `json:"content_html"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ReadingMinutes int `json:"reading_minutes"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type PostInput struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
ContentMd string `json:"content_md"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Tags []string `json:"tags"`
|
||||
ReadingMinutes *int `json:"reading_minutes"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type ArchiveMonth struct {
|
||||
Month string `json:"month"`
|
||||
Posts []Post `json:"posts"`
|
||||
}
|
||||
|
||||
type ArchiveYear struct {
|
||||
Year string `json:"year"`
|
||||
Months []ArchiveMonth `json:"months"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Items []Post `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
SiteTitle string `json:"site_title"`
|
||||
SiteDesc string `json:"site_desc"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorBio string `json:"author_bio"`
|
||||
FooterNote string `json:"footer_note"`
|
||||
ICPLicense string `json:"icp"`
|
||||
PostsPerPage int `json:"posts_per_page"`
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package render turns Markdown into sanitized HTML for the public site.
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
var md = goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM, extension.Footnote),
|
||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||
goldmark.WithRendererOptions(html.WithHardWraps()),
|
||||
)
|
||||
|
||||
var unsafeScheme = regexp.MustCompile(`(?i)(href|src)\s*=\s*"(javascript|data|vbscript):[^"]*"`)
|
||||
|
||||
// Markdown renders Markdown to HTML. Raw HTML stays escaped (goldmark default)
|
||||
// and dangerous URL schemes are stripped.
|
||||
func Markdown(src string) string {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(src), &buf); err != nil {
|
||||
return "<p>" + escapeHTML(src) + "</p>"
|
||||
}
|
||||
out := buf.String()
|
||||
out = unsafeScheme.ReplaceAllString(out, `$1="#"`)
|
||||
return out
|
||||
}
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// ReadingMinutes estimates reading time: ~400 CJK chars or ~220 latin words
|
||||
// per minute, whichever dominates.
|
||||
func ReadingMinutes(markdown string) int {
|
||||
if strings.TrimSpace(markdown) == "" {
|
||||
return 1
|
||||
}
|
||||
cjk := 0
|
||||
latinWords := 0
|
||||
inWord := false
|
||||
for _, r := range markdown {
|
||||
switch {
|
||||
case r >= 0x4E00 && r <= 0x9FFF, r >= 0x3400 && r <= 0x4DBF,
|
||||
r >= 0x3000 && r <= 0x303F, r >= 0xFF00 && r <= 0xFFEF:
|
||||
cjk++
|
||||
inWord = false
|
||||
case unicode.IsSpace(r):
|
||||
inWord = false
|
||||
default:
|
||||
if !inWord {
|
||||
latinWords++
|
||||
inWord = true
|
||||
}
|
||||
}
|
||||
}
|
||||
minutes := cjk/400 + latinWords/220
|
||||
if minutes < 1 {
|
||||
return 1
|
||||
}
|
||||
return minutes
|
||||
}
|
||||
|
||||
// Excerpt builds a plain-text summary from Markdown when the author left the
|
||||
// summary field empty.
|
||||
func Excerpt(markdown string, limit int) string {
|
||||
var b strings.Builder
|
||||
inFence := false
|
||||
for _, line := range strings.Split(markdown, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(t, "```") {
|
||||
inFence = !inFence
|
||||
continue
|
||||
}
|
||||
if inFence || t == "" || strings.HasPrefix(t, "#") || strings.HasPrefix(t, ">") {
|
||||
continue
|
||||
}
|
||||
t = strings.TrimLeft(t, "-*+0123456789. ")
|
||||
b.WriteString(t)
|
||||
b.WriteString(" ")
|
||||
}
|
||||
s := strings.TrimSpace(b.String())
|
||||
if limit <= 0 {
|
||||
limit = 140
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= limit {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:limit]) + "…"
|
||||
}
|
||||
|
||||
// TitleFromMarkdown derives a fallback title for short posts.
|
||||
func TitleFromMarkdown(markdown string) string {
|
||||
for _, line := range strings.Split(markdown, "\n") {
|
||||
t := strings.TrimSpace(strings.TrimLeft(line, "# "))
|
||||
if t != "" {
|
||||
runes := []rune(t)
|
||||
if len(runes) > 24 {
|
||||
return string(runes[:24]) + "…"
|
||||
}
|
||||
return t
|
||||
}
|
||||
}
|
||||
return "无题"
|
||||
}
|
||||
|
||||
func RuneLen(s string) int { return utf8.RuneCountInString(s) }
|
||||
@@ -0,0 +1,49 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownRendersStructure(t *testing.T) {
|
||||
out := Markdown("## 标题\n\n正文 **粗体**。\n\n- 一\n- 二\n\n> 引用\n")
|
||||
for _, want := range []string{"<h2", "<strong>粗体</strong>", "<li>一</li>", "<blockquote>"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q in %q", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownEscapesRawHTML(t *testing.T) {
|
||||
out := Markdown("<script>alert(1)</script>")
|
||||
if strings.Contains(out, "<script>") {
|
||||
t.Errorf("raw HTML should be escaped, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownBlocksUnsafeSchemes(t *testing.T) {
|
||||
out := Markdown(`[x](javascript:alert(1))`)
|
||||
if strings.Contains(out, "javascript:") {
|
||||
t.Errorf("unsafe scheme should be stripped, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadingMinutes(t *testing.T) {
|
||||
if got := ReadingMinutes(""); got != 1 {
|
||||
t.Errorf("empty content should be 1 minute, got %d", got)
|
||||
}
|
||||
long := strings.Repeat("字", 1200)
|
||||
if got := ReadingMinutes(long); got != 3 {
|
||||
t.Errorf("1200 CJK chars should be 3 minutes, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcerpt(t *testing.T) {
|
||||
got := Excerpt("# 标题\n\n这是正文。\n\n```go\nfmt.Println()\n```", 140)
|
||||
if strings.Contains(got, "fmt.Println") {
|
||||
t.Errorf("code fences should be dropped, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "这是正文。") {
|
||||
t.Errorf("excerpt should keep body text, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
// Package store owns the schema and every SQL query. All SQL is written with
|
||||
// `?` placeholders and re-bound to `$n` when running on PostgreSQL.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/render"
|
||||
)
|
||||
|
||||
func renderHTML(md string) string { return render.Markdown(md) }
|
||||
|
||||
func readingMinutes(md string) int { return render.ReadingMinutes(md) }
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func New(d *db.DB) (*Store, error) {
|
||||
s := &Store{db: d}
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func now() string { return time.Now().UTC().Format(time.RFC3339) }
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
ai := s.db.AutoInc()
|
||||
stmts := []string{
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS posts (
|
||||
id %s,
|
||||
kind TEXT NOT NULL DEFAULT 'long',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
slug TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
content_md TEXT NOT NULL DEFAULT '',
|
||||
content_html TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
published_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
reading_minutes INTEGER NOT NULL DEFAULT 1
|
||||
)`, ai),
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS tags (
|
||||
id %s,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL
|
||||
)`, ai),
|
||||
`CREATE TABLE IF NOT EXISTS post_tags (
|
||||
post_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (post_id, tag_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Indexes/unique constraints need dialect-specific "IF NOT EXISTS" support.
|
||||
indexes := []struct{ name, ddl string }{
|
||||
{"idx_posts_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)`},
|
||||
{"idx_posts_feed", `CREATE INDEX IF NOT EXISTS idx_posts_feed ON posts(status, published_at DESC)`},
|
||||
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
|
||||
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
|
||||
}
|
||||
for _, ix := range indexes {
|
||||
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
return fmt.Errorf("migrate index %s: %w", ix.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.seedSettings()
|
||||
}
|
||||
|
||||
func (s *Store) seedSettings() error {
|
||||
defs := map[string]string{
|
||||
"site_title": "ONE · 一个博客",
|
||||
"site_desc": "长文与短文,同一种节奏。",
|
||||
"author_name": "ONE",
|
||||
"author_bio": "写点长的,也写点短的。",
|
||||
"footer_note": "© ONE · 一个博客",
|
||||
"icp": "",
|
||||
"posts_per_page": "10",
|
||||
}
|
||||
for k, v := range defs {
|
||||
if s.db.Dialect == db.Postgres {
|
||||
_, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
||||
ON CONFLICT (key) DO NOTHING`), k, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT OR IGNORE INTO settings(key,value) VALUES (?,?)`), k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
|
||||
func (s *Store) GetSettings() (model.Settings, error) {
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT key, value FROM settings`))
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
m := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
return settingsFromMap(m), rows.Err()
|
||||
}
|
||||
|
||||
func settingsFromMap(m map[string]string) model.Settings {
|
||||
st := model.Settings{
|
||||
SiteTitle: m["site_title"],
|
||||
SiteDesc: m["site_desc"],
|
||||
AuthorName: m["author_name"],
|
||||
AuthorBio: m["author_bio"],
|
||||
FooterNote: m["footer_note"],
|
||||
ICPLicense: m["icp"],
|
||||
PostsPerPage: 10,
|
||||
}
|
||||
if n := atoi(m["posts_per_page"]); n > 0 {
|
||||
st.PostsPerPage = n
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func atoi(v string) int {
|
||||
n := 0
|
||||
for _, r := range v {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSettings(st model.Settings) error {
|
||||
if st.PostsPerPage <= 0 {
|
||||
st.PostsPerPage = 10
|
||||
}
|
||||
sets := map[string]string{
|
||||
"site_title": st.SiteTitle,
|
||||
"site_desc": st.SiteDesc,
|
||||
"author_name": st.AuthorName,
|
||||
"author_bio": st.AuthorBio,
|
||||
"footer_note": st.FooterNote,
|
||||
"icp": st.ICPLicense,
|
||||
"posts_per_page": fmt.Sprint(st.PostsPerPage),
|
||||
}
|
||||
for k, v := range sets {
|
||||
if s.db.Dialect == db.Postgres {
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`), k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`), k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- posts ----------
|
||||
|
||||
type ListOptions struct {
|
||||
Kind string
|
||||
Tag string
|
||||
Query string
|
||||
Status string // "" = published only (public), "any" = all (admin)
|
||||
Page int
|
||||
Size int
|
||||
OrderBy string
|
||||
}
|
||||
|
||||
const postCols = `id, kind, title, slug, summary, content_md, content_html, status,
|
||||
published_at, created_at, updated_at, reading_minutes`
|
||||
|
||||
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
|
||||
var p model.Post
|
||||
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.ContentMd,
|
||||
&p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes)
|
||||
p.Tags = []string{}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Store) List(o ListOptions) (model.Page, error) {
|
||||
if o.Page < 1 {
|
||||
o.Page = 1
|
||||
}
|
||||
if o.Size < 1 || o.Size > 100 {
|
||||
o.Size = 10
|
||||
}
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
if o.Status == "any" {
|
||||
// admin: no status filter
|
||||
} else if o.Status != "" {
|
||||
where = append(where, "status = ?")
|
||||
args = append(args, o.Status)
|
||||
} else {
|
||||
where = append(where, "status = 'published'")
|
||||
}
|
||||
if o.Kind != "" {
|
||||
where = append(where, "kind = ?")
|
||||
args = append(args, o.Kind)
|
||||
}
|
||||
if o.Tag != "" {
|
||||
where = append(where, `id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE t.slug = ? OR t.name = ?)`)
|
||||
args = append(args, o.Tag, o.Tag)
|
||||
}
|
||||
if o.Query != "" {
|
||||
like := "%" + strings.ToLower(o.Query) + "%"
|
||||
where = append(where, `(lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(content_md) LIKE ?)`)
|
||||
args = append(args, like, like, like)
|
||||
}
|
||||
w := ""
|
||||
if len(where) > 0 {
|
||||
w = "WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
order := "published_at DESC"
|
||||
if o.OrderBy != "" {
|
||||
order = o.OrderBy
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM posts `+w), args...).Scan(&total); err != nil {
|
||||
return model.Page{}, err
|
||||
}
|
||||
|
||||
q := s.db.Q(fmt.Sprintf(`SELECT %s FROM posts %s ORDER BY %s LIMIT ? OFFSET ?`, postCols, w, order))
|
||||
rows, err := s.db.Query(q, append(args, o.Size, (o.Page-1)*o.Size)...)
|
||||
if err != nil {
|
||||
return model.Page{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []model.Post{}
|
||||
for rows.Next() {
|
||||
p, err := scanPost(rows)
|
||||
if err != nil {
|
||||
return model.Page{}, err
|
||||
}
|
||||
items = append(items, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return model.Page{}, err
|
||||
}
|
||||
if err := s.attachTags(items); err != nil {
|
||||
return model.Page{}, err
|
||||
}
|
||||
return model.Page{Items: items, Total: total, Page: o.Page, Size: o.Size}, nil
|
||||
}
|
||||
|
||||
func (s *Store) attachTags(posts []model.Post) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]any, 0, len(posts))
|
||||
idx := map[int64]int{}
|
||||
for i, p := range posts {
|
||||
ids = append(ids, p.ID)
|
||||
idx[p.ID] = i
|
||||
}
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||
q := s.db.Q(fmt.Sprintf(`SELECT pt.post_id, t.name FROM post_tags pt
|
||||
JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id IN (%s) ORDER BY t.name`, ph))
|
||||
rows, err := s.db.Query(q, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pid int64
|
||||
var name string
|
||||
if err := rows.Scan(&pid, &name); err != nil {
|
||||
return err
|
||||
}
|
||||
if i, ok := idx[pid]; ok {
|
||||
posts[i].Tags = append(posts[i].Tags, name)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Get(id int64) (model.Post, error) {
|
||||
var p model.Post
|
||||
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE id = ?`), id)
|
||||
if err := scanPostInto(row, &p); err != nil {
|
||||
return p, err
|
||||
}
|
||||
items := []model.Post{p}
|
||||
if err := s.attachTags(items); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
func (s *Store) GetBySlug(slug string) (model.Post, error) {
|
||||
var p model.Post
|
||||
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE slug = ?`), slug)
|
||||
if err := scanPostInto(row, &p); err != nil {
|
||||
return p, err
|
||||
}
|
||||
items := []model.Post{p}
|
||||
if err := s.attachTags(items); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
func scanPostInto(row *sql.Row, p *model.Post) error {
|
||||
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.ContentMd,
|
||||
&p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes)
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Tags = []string{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- slug ----------
|
||||
|
||||
var slugSep = regexp.MustCompile(`[^\p{L}\p{N}]+`)
|
||||
|
||||
func Slugify(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
s = slugSep.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Store) uniqueSlug(base string, excludeID int64) string {
|
||||
base = Slugify(base)
|
||||
if base == "" {
|
||||
base = "post"
|
||||
}
|
||||
candidate := base
|
||||
for i := 2; ; i++ {
|
||||
var id int64
|
||||
err := s.db.QueryRow(s.db.Q(`SELECT id FROM posts WHERE slug = ? AND id <> ?`), candidate, excludeID).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return candidate
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%s-%d", base, time.Now().Unix())
|
||||
}
|
||||
candidate = fmt.Sprintf("%s-%d", base, i)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- write ----------
|
||||
|
||||
func (s *Store) Create(in model.PostInput) (model.Post, error) {
|
||||
p := model.Post{
|
||||
Kind: in.Kind,
|
||||
Title: strings.TrimSpace(in.Title),
|
||||
Slug: in.Slug,
|
||||
Status: in.Status,
|
||||
Tags: []string{},
|
||||
}
|
||||
if p.Kind == "" {
|
||||
p.Kind = model.KindLong
|
||||
}
|
||||
if p.Status == "" {
|
||||
p.Status = model.StatusDraft
|
||||
}
|
||||
if p.Slug == "" {
|
||||
p.Slug = Slugify(p.Title)
|
||||
}
|
||||
if p.Slug == "" {
|
||||
// Titles without any latin characters (typical for short notes) get a
|
||||
// date-based slug instead of colliding on "post", "post-2", ...
|
||||
p.Slug = "s-" + time.Now().UTC().Format("20060102-150405")
|
||||
}
|
||||
p.Slug = s.uniqueSlug(p.Slug, 0)
|
||||
// Short posts have no visible title, but archive and tag listings still
|
||||
// need something to index them by.
|
||||
if p.Kind == model.KindShort && p.Title == "" {
|
||||
p.Title = render.TitleFromMarkdown(in.ContentMd)
|
||||
}
|
||||
p.Summary = strings.TrimSpace(in.Summary)
|
||||
p.ContentMd = in.ContentMd
|
||||
p.ContentHTML = renderHTML(in.ContentMd)
|
||||
p.PublishedAt = in.PublishedAt
|
||||
p.CreatedAt = now()
|
||||
p.UpdatedAt = p.CreatedAt
|
||||
if p.PublishedAt == "" {
|
||||
p.PublishedAt = p.CreatedAt
|
||||
}
|
||||
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
|
||||
p.ReadingMinutes = *in.ReadingMinutes
|
||||
} else {
|
||||
p.ReadingMinutes = readingMinutes(in.ContentMd)
|
||||
}
|
||||
|
||||
var id int64
|
||||
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,content_md,content_html,status,
|
||||
published_at,created_at,updated_at,reading_minutes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
if s.db.Dialect == db.Postgres {
|
||||
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML,
|
||||
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
} else {
|
||||
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML,
|
||||
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
id, err = res.LastInsertId()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
}
|
||||
p.ID = id
|
||||
if err := s.setTags(id, in.Tags); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return s.Get(id)
|
||||
}
|
||||
|
||||
func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
|
||||
cur, err := s.Get(id)
|
||||
if err != nil {
|
||||
return cur, err
|
||||
}
|
||||
p := cur
|
||||
if in.Kind != "" {
|
||||
p.Kind = in.Kind
|
||||
}
|
||||
if in.Title != "" || in.Kind == model.KindShort {
|
||||
p.Title = strings.TrimSpace(in.Title)
|
||||
}
|
||||
if in.Summary != "" {
|
||||
p.Summary = strings.TrimSpace(in.Summary)
|
||||
}
|
||||
if in.ContentMd != "" {
|
||||
p.ContentMd = in.ContentMd
|
||||
p.ContentHTML = renderHTML(in.ContentMd)
|
||||
}
|
||||
if in.Status != "" {
|
||||
p.Status = in.Status
|
||||
}
|
||||
if in.Slug != "" && in.Slug != cur.Slug {
|
||||
p.Slug = s.uniqueSlug(in.Slug, id)
|
||||
}
|
||||
if in.PublishedAt != "" {
|
||||
p.PublishedAt = in.PublishedAt
|
||||
}
|
||||
p.UpdatedAt = now()
|
||||
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
|
||||
p.ReadingMinutes = *in.ReadingMinutes
|
||||
} else if in.ContentMd != "" {
|
||||
p.ReadingMinutes = readingMinutes(in.ContentMd)
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,content_md=?,
|
||||
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`),
|
||||
p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML, p.Status,
|
||||
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil {
|
||||
return p, err
|
||||
}
|
||||
if in.Tags != nil {
|
||||
if err := s.setTags(id, in.Tags); err != nil {
|
||||
return p, err
|
||||
}
|
||||
}
|
||||
return s.Get(id)
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id int64) error {
|
||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.db.Exec(s.db.Q(`DELETE FROM posts WHERE id = ?`), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err == nil && n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- tags ----------
|
||||
|
||||
func (s *Store) setTags(postID int64, names []string) error {
|
||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), postID); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range names {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
tagID, err := s.upsertTag(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?,?)`), postID, tagID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) upsertTag(name string) (int64, error) {
|
||||
slug := Slugify(name)
|
||||
if slug == "" {
|
||||
slug = "tag"
|
||||
}
|
||||
var id int64
|
||||
err := s.db.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return 0, err
|
||||
}
|
||||
if s.db.Dialect == db.Postgres {
|
||||
err = s.db.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
res, err := s.db.Exec(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?)`), name, slug)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (s *Store) ListTags() ([]model.Tag, error) {
|
||||
q := s.db.Q(`SELECT t.id, t.name, t.slug, COUNT(pt.post_id) AS c
|
||||
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
|
||||
GROUP BY t.id, t.name, t.slug ORDER BY c DESC, t.name`)
|
||||
rows, err := s.db.Query(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Tag{}
|
||||
for rows.Next() {
|
||||
var t model.Tag
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateTag(name string) (model.Tag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return model.Tag{}, errors.New("tag name required")
|
||||
}
|
||||
id, err := s.upsertTag(name)
|
||||
if err != nil {
|
||||
return model.Tag{}, err
|
||||
}
|
||||
return model.Tag{ID: id, Name: name, Slug: Slugify(name)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RenameTag(id int64, name string) (model.Tag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
slug := Slugify(name)
|
||||
if name == "" {
|
||||
return model.Tag{}, errors.New("tag name required")
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=? WHERE id=?`), name, slug, id); err != nil {
|
||||
return model.Tag{}, err
|
||||
}
|
||||
var t model.Tag
|
||||
err := s.db.QueryRow(s.db.Q(`SELECT id, name, slug, 0 FROM tags WHERE id = ?`), id).
|
||||
Scan(&t.ID, &t.Name, &t.Slug, &t.Count)
|
||||
if err == sql.ErrNoRows {
|
||||
return t, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTag(id int64) error {
|
||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE tag_id = ?`), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`DELETE FROM tags WHERE id = ?`), id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- archive ----------
|
||||
|
||||
func (s *Store) Archive() ([]model.ArchiveYear, error) {
|
||||
page, err := s.List(ListOptions{Status: model.StatusPublished, Page: 1, Size: 500})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
years := []model.ArchiveYear{}
|
||||
yearIdx := map[string]int{}
|
||||
monthIdx := map[string]int{}
|
||||
for _, p := range page.Items {
|
||||
y, m := splitDate(p.PublishedAt)
|
||||
if y == "" {
|
||||
continue
|
||||
}
|
||||
yi, ok := yearIdx[y]
|
||||
if !ok {
|
||||
years = append(years, model.ArchiveYear{Year: y, Months: []model.ArchiveMonth{}})
|
||||
yi = len(years) - 1
|
||||
yearIdx[y] = yi
|
||||
}
|
||||
key := y + "-" + m
|
||||
mi, ok := monthIdx[key]
|
||||
if !ok {
|
||||
years[yi].Months = append(years[yi].Months, model.ArchiveMonth{Month: m, Posts: []model.Post{}})
|
||||
mi = len(years[yi].Months) - 1
|
||||
monthIdx[key] = mi
|
||||
}
|
||||
years[yi].Count++
|
||||
years[yi].Months[mi].Posts = append(years[yi].Months[mi].Posts, p)
|
||||
}
|
||||
return years, nil
|
||||
}
|
||||
|
||||
func splitDate(rfc3339 string) (string, string) {
|
||||
if len(rfc3339) < 7 {
|
||||
return "", ""
|
||||
}
|
||||
return rfc3339[0:4], rfc3339[5:7]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/model"
|
||||
)
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Hello World": "hello-world",
|
||||
" Rebuild ONE ": "rebuild-one",
|
||||
"Go 语言 / 2026": "go-语言-2026",
|
||||
"!!!": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Slugify(in); got != want {
|
||||
t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOptionsRebind(t *testing.T) {
|
||||
// 列表查询的占位符数量必须和参数数量一致,否则在 PostgreSQL 上会直接报错
|
||||
d := &db.DB{Dialect: db.Postgres}
|
||||
q := d.Q(`SELECT id FROM posts WHERE kind = ? AND status = ? AND title LIKE ? LIMIT ? OFFSET ?`)
|
||||
if strings.Count(q, "$") != 5 {
|
||||
t.Errorf("expected 5 placeholders, got %q", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostInputDefaults(t *testing.T) {
|
||||
in := model.PostInput{Kind: model.KindShort, ContentMd: "一句话。"}
|
||||
if in.Kind != "short" {
|
||||
t.Errorf("kind = %q", in.Kind)
|
||||
}
|
||||
if in.Status != "" {
|
||||
t.Errorf("empty status means draft is applied by the API layer, got %q", in.Status)
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
// Command one-server is the ONE blog backend: public API, admin API, RSS and
|
||||
// (when built) the static frontend.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/admin"
|
||||
"oneblog/internal/api"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "", "listen address (overrides ONE_ADDR)")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
if *addr != "" {
|
||||
cfg.Addr = *addr
|
||||
}
|
||||
|
||||
pool, err := db.Open(cfg.Driver, cfg.DSN)
|
||||
if err != nil {
|
||||
log.Fatalf("database: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
st, err := store.New(pool)
|
||||
if err != nil {
|
||||
log.Fatalf("store: %v", err)
|
||||
}
|
||||
|
||||
public := &api.API{Store: st, Cfg: cfg}
|
||||
adminAPI := &admin.API{Store: st, Cfg: cfg, Sessions: admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)}
|
||||
|
||||
root := http.NewServeMux()
|
||||
root.Handle("/api/admin/", adminAPI.Routes())
|
||||
public.Mount(root)
|
||||
root.Handle("/", spaHandler(cfg.WebDist))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: requestLog(root),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("ONE server listening on %s (db=%s)", cfg.Addr, cfg.Driver)
|
||||
if cfg.InsecureDev {
|
||||
log.Printf("admin login: %s / %s (set ONE_ADMIN_PASSWORD to change)", cfg.AdminUser, cfg.AdminPass)
|
||||
} else {
|
||||
log.Printf("admin login: %s / (from ONE_ADMIN_PASSWORD)", cfg.AdminUser)
|
||||
}
|
||||
if !dirExists(cfg.WebDist) {
|
||||
log.Printf("frontend build not found at %s — run `make web` or `make dev` (API still available)", cfg.WebDist)
|
||||
}
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
log.Print("bye")
|
||||
}
|
||||
|
||||
// spaHandler serves the built Vue app and falls back to index.html so client
|
||||
// routes like /post/foo work on refresh.
|
||||
func spaHandler(dist string) http.Handler {
|
||||
fs := http.Dir(dist)
|
||||
fileServer := http.FileServer(fs)
|
||||
index := filepath.Join(dist, "index.html")
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !dirExists(dist) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, placeholderPage())
|
||||
return
|
||||
}
|
||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if p == "" {
|
||||
p = "index.html"
|
||||
}
|
||||
if _, err := fs.Open(p); err == nil && hasExt(p) {
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, index)
|
||||
})
|
||||
}
|
||||
|
||||
func hasExt(p string) bool {
|
||||
return strings.Contains(filepath.Base(p), ".")
|
||||
}
|
||||
|
||||
func dirExists(p string) bool {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && fi.IsDir()
|
||||
}
|
||||
|
||||
func requestLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
if strings.HasPrefix(r.URL.Path, "/api") || r.URL.Path == "/rss.xml" {
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.RequestURI(), rec.status, time.Since(start).Truncate(time.Millisecond))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) { s.status = code; s.ResponseWriter.WriteHeader(code) }
|
||||
|
||||
func placeholderPage() string {
|
||||
return `<!doctype html><meta charset="utf-8"><title>ONE</title>
|
||||
<style>body{font-family:-apple-system,"PingFang SC",sans-serif;background:#faf7f1;color:#33302b;
|
||||
display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
div{max-width:520px;line-height:1.95;padding:0 24px}
|
||||
code{background:#efe9dd;padding:2px 6px;border-radius:3px}</style>
|
||||
<div><h1 style="font-family:Georgia,serif">ONE · 一个博客</h1>
|
||||
<p>后端已启动,但前端还没构建。</p>
|
||||
<p>在项目根目录执行 <code>make web</code> 构建前端,或 <code>make dev</code> 同时起前后端。</p>
|
||||
<p>接口可用:<code>/api/posts</code>、<code>/api/archive</code>、<code>/api/tags</code>、<code>/rss.xml</code>;后台接口在 <code>/api/admin/</code>。</p></div>`
|
||||
}
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>xLog</title>
|
||||
<title>ONE · 一个博客</title>
|
||||
<meta name="description" content="长文与短文,同一种节奏。" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件:仅承载路由出口
|
||||
<script setup>
|
||||
import TopBar from './components/TopBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<TopBar />
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { checkAuth } from './auth'
|
||||
import { adminApi, session } from '../api'
|
||||
import { site } from '../site'
|
||||
|
||||
const router = useRouter()
|
||||
const ready = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!(await checkAuth())) {
|
||||
router.replace('/admin/login')
|
||||
return
|
||||
}
|
||||
ready.value = true
|
||||
})
|
||||
|
||||
// 会话过期时(请求返回 401)统一跳回登录页
|
||||
window.addEventListener('one:unauthorized', () => {
|
||||
session.clear()
|
||||
if (router.currentRoute.value.path.startsWith('/admin')) router.replace('/admin/login')
|
||||
})
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await adminApi.logout()
|
||||
} catch (e) {
|
||||
/* 忽略 */
|
||||
}
|
||||
session.clear()
|
||||
router.replace('/admin/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="ready" class="admin">
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<RouterLink to="/" class="site">{{ site.site_title || 'ONE' }}</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<span class="where">后台</span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<RouterLink to="/admin" class="link">文章</RouterLink>
|
||||
<RouterLink to="/admin/new" class="link">写新的</RouterLink>
|
||||
<RouterLink to="/admin/tags" class="link">标签</RouterLink>
|
||||
<RouterLink to="/admin/settings" class="link">设置</RouterLink>
|
||||
<RouterLink to="/" class="link">看前台</RouterLink>
|
||||
<button class="link as-btn" @click="logout">退出</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
<div v-else class="checking">检查登录状态…</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.site {
|
||||
font-family: var(--serif);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.where {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link {
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
color: var(--ink-soft);
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
background: var(--paper-sunken);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.link.router-link-exact-active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.as-btn {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 26px 22px 80px;
|
||||
}
|
||||
|
||||
.checking {
|
||||
padding: 80px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,605 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminApi } from '../api'
|
||||
import { md } from '../utils'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const id = computed(() => (route.params.id ? Number(route.params.id) : 0))
|
||||
const isEdit = computed(() => id.value > 0)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const savedAt = ref('')
|
||||
const error = ref('')
|
||||
const tab = ref('write') // write | preview
|
||||
const tagInput = ref('')
|
||||
const dirty = ref(false)
|
||||
|
||||
const DRAFT_KEY = 'one.draft.new'
|
||||
|
||||
const form = reactive({
|
||||
kind: 'long',
|
||||
title: '',
|
||||
slug: '',
|
||||
summary: '',
|
||||
content_md: '',
|
||||
tags: [],
|
||||
status: 'draft',
|
||||
published_at: '',
|
||||
reading_minutes: null,
|
||||
override_minutes: false
|
||||
})
|
||||
|
||||
// ---------- 阅读时长 ----------
|
||||
|
||||
function estimateMinutes(text) {
|
||||
if (!text || !text.trim()) return 1
|
||||
const cjk = (text.match(/[㐀-鿿 -〿-]/g) || []).length
|
||||
const latin = (text.match(/[A-Za-z0-9']+/g) || []).length
|
||||
return Math.max(1, Math.floor(cjk / 400) + Math.floor(latin / 220))
|
||||
}
|
||||
|
||||
const autoMinutes = computed(() => estimateMinutes(form.content_md))
|
||||
const finalMinutes = computed(() =>
|
||||
form.override_minutes && form.reading_minutes ? Number(form.reading_minutes) : autoMinutes.value
|
||||
)
|
||||
|
||||
// ---------- 时间 ----------
|
||||
|
||||
function isoToLocal(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(
|
||||
d.getMinutes()
|
||||
)}`
|
||||
}
|
||||
|
||||
function localToIso(local) {
|
||||
if (!local) return ''
|
||||
const d = new Date(local)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
const publishedLocal = ref('')
|
||||
|
||||
// ---------- 载入 ----------
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const p = await adminApi.post(id.value)
|
||||
form.kind = p.kind || 'long'
|
||||
form.title = p.title || ''
|
||||
form.slug = p.slug || ''
|
||||
form.summary = p.summary || ''
|
||||
form.content_md = p.content_md || ''
|
||||
form.tags = p.tags ? [...p.tags] : []
|
||||
form.status = p.status || 'draft'
|
||||
publishedLocal.value = isoToLocal(p.published_at)
|
||||
if (p.reading_minutes && p.reading_minutes !== estimateMinutes(p.content_md || '')) {
|
||||
form.override_minutes = true
|
||||
form.reading_minutes = p.reading_minutes
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message || '载入失败'
|
||||
}
|
||||
} else {
|
||||
const raw = localStorage.getItem(DRAFT_KEY)
|
||||
if (raw) {
|
||||
try {
|
||||
Object.assign(form, JSON.parse(raw))
|
||||
savedAt.value = '本地草稿已恢复'
|
||||
} catch (e) {
|
||||
localStorage.removeItem(DRAFT_KEY)
|
||||
}
|
||||
}
|
||||
publishedLocal.value = isoToLocal(new Date().toISOString())
|
||||
}
|
||||
loading.value = false
|
||||
// 载入完成后的第一次变更才触发自动保存
|
||||
setTimeout(() => {
|
||||
dirty.value = false
|
||||
watchForm()
|
||||
}, 0)
|
||||
})
|
||||
|
||||
// ---------- 自动保存 ----------
|
||||
|
||||
let timer
|
||||
let stopWatch
|
||||
|
||||
function watchForm() {
|
||||
stopWatch = watch(
|
||||
form,
|
||||
() => {
|
||||
dirty.value = true
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(autosave, 1500)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
watch(publishedLocal, () => {
|
||||
dirty.value = true
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(autosave, 1500)
|
||||
})
|
||||
}
|
||||
|
||||
async function autosave() {
|
||||
if (loading.value) return
|
||||
const payload = buildPayload()
|
||||
if (!payload.content_md.trim() && !payload.title.trim() && !isEdit.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const saved = await adminApi.updatePost(id.value, payload)
|
||||
applySaved(saved)
|
||||
} else {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ ...form, tags: [...form.tags] }))
|
||||
}
|
||||
dirty.value = false
|
||||
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
} catch (e) {
|
||||
error.value = '自动保存失败:' + (e.message || '')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applySaved(saved) {
|
||||
form.slug = saved.slug
|
||||
form.status = saved.status
|
||||
publishedLocal.value = isoToLocal(saved.published_at)
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
kind: form.kind,
|
||||
title: form.title,
|
||||
slug: form.slug,
|
||||
summary: form.summary,
|
||||
content_md: form.content_md,
|
||||
tags: [...form.tags],
|
||||
status: form.status,
|
||||
published_at: localToIso(publishedLocal.value) || new Date().toISOString(),
|
||||
reading_minutes: form.override_minutes && form.reading_minutes ? Number(form.reading_minutes) : null
|
||||
}
|
||||
}
|
||||
|
||||
async function save(status) {
|
||||
if (status) form.status = status
|
||||
if (!isEdit.value && !form.content_md.trim() && !form.title.trim()) {
|
||||
error.value = '先写点什么再保存'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
clearTimeout(timer)
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
if (isEdit.value) {
|
||||
applySaved(await adminApi.updatePost(id.value, payload))
|
||||
} else {
|
||||
const created = await adminApi.createPost(payload)
|
||||
localStorage.removeItem(DRAFT_KEY)
|
||||
dirty.value = false
|
||||
router.replace(`/admin/${created.id}`)
|
||||
return
|
||||
}
|
||||
dirty.value = false
|
||||
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
} catch (e) {
|
||||
error.value = e.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publishNow() {
|
||||
await save('published')
|
||||
}
|
||||
|
||||
async function unpublish() {
|
||||
await save('draft')
|
||||
}
|
||||
|
||||
// ---------- 标签 ----------
|
||||
|
||||
function addTag() {
|
||||
const v = tagInput.value.trim().replace(/[,,]$/, '')
|
||||
if (!v) return
|
||||
if (!form.tags.includes(v)) form.tags.push(v)
|
||||
tagInput.value = ''
|
||||
}
|
||||
|
||||
function onTagInput(e) {
|
||||
if (e.key === 'Enter' || e.key === ',' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
addTag()
|
||||
}
|
||||
}
|
||||
|
||||
function removeTag(t) {
|
||||
form.tags = form.tags.filter((x) => x !== t)
|
||||
}
|
||||
|
||||
// ---------- 预览 ----------
|
||||
|
||||
const preview = computed(() => md.render(form.content_md || ''))
|
||||
|
||||
function onKeydown(e) {
|
||||
// Tab 缩进,而不是跳出输入框
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const el = e.target
|
||||
const start = el.selectionStart
|
||||
const end = el.selectionEnd
|
||||
form.content_md =
|
||||
form.content_md.slice(0, start) + ' ' + form.content_md.slice(end)
|
||||
setTimeout(() => el.setSelectionRange(start + 2, start + 2))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="loading" class="loading">载入中…</section>
|
||||
|
||||
<section v-else class="editor">
|
||||
<header class="head">
|
||||
<div class="left">
|
||||
<h1 class="title">{{ isEdit ? '编辑文章' : '写新的' }}</h1>
|
||||
<span class="state">
|
||||
<span v-if="saving">保存中…</span>
|
||||
<span v-else-if="dirty">有未保存的改动</span>
|
||||
<span v-else-if="savedAt">已保存 {{ savedAt }}</span>
|
||||
<span v-else>还没改过</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<button class="btn" @click="form.status === 'published' ? unpublish() : save()">
|
||||
存草稿
|
||||
</button>
|
||||
<button v-if="form.status !== 'published'" class="btn btn-primary" @click="publishNow">
|
||||
发布
|
||||
</button>
|
||||
<button v-else class="btn" @click="unpublish">转为草稿</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div class="grid">
|
||||
<!-- 左:正文 -->
|
||||
<div class="main">
|
||||
<div class="kind-switch">
|
||||
<button class="kind" :class="{ on: form.kind === 'long' }" @click="form.kind = 'long'">
|
||||
长文
|
||||
</button>
|
||||
<button class="kind" :class="{ on: form.kind === 'short' }" @click="form.kind = 'short'">
|
||||
短文
|
||||
</button>
|
||||
<span class="hint">
|
||||
{{ form.kind === 'long' ? '有标题、有结构,适合讲完整一件事' : '一两段话,时间线里不显示标题' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="input title-input"
|
||||
:placeholder="form.kind === 'short' ? '标题(可留空,仅作归档索引)' : '标题'"
|
||||
/>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ on: tab === 'write' }" @click="tab = 'write'">编写</button>
|
||||
<button class="tab" :class="{ on: tab === 'preview' }" @click="tab = 'preview'">预览</button>
|
||||
<span class="counter">{{ form.content_md.length }} 字</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-show="tab === 'write'"
|
||||
v-model="form.content_md"
|
||||
class="textarea md-input"
|
||||
rows="20"
|
||||
:placeholder="form.kind === 'short' ? '写点什么…' : '# 标题\n\n正文,支持 Markdown。'"
|
||||
@keydown="onKeydown"
|
||||
></textarea>
|
||||
|
||||
<div v-show="tab === 'preview'" class="prose preview" v-html="preview"></div>
|
||||
</div>
|
||||
|
||||
<!-- 右:元信息 -->
|
||||
<aside class="side">
|
||||
<div class="field">
|
||||
<label>类型</label>
|
||||
<p class="value">{{ form.kind === 'short' ? '短文' : '长文' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="slug">Slug(链接)</label>
|
||||
<input id="slug" v-model="form.slug" class="input" placeholder="留空自动生成" />
|
||||
<p v-if="form.slug" class="sub">/post/{{ form.slug }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="summary">摘要</label>
|
||||
<textarea
|
||||
id="summary"
|
||||
v-model="form.summary"
|
||||
class="textarea"
|
||||
rows="3"
|
||||
placeholder="长文显示在时间线里的一段话"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>标签</label>
|
||||
<div v-if="form.tags.length" class="taglist">
|
||||
<span v-for="t in form.tags" :key="t" class="tag-chip">
|
||||
{{ t }}
|
||||
<button class="x" @click="removeTag(t)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="tagInput"
|
||||
class="input"
|
||||
placeholder="输入后回车添加"
|
||||
@keydown="onTagInput"
|
||||
@blur="addTag"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pub">发布时间</label>
|
||||
<input id="pub" v-model="publishedLocal" type="datetime-local" class="input" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="mins">阅读时长(分钟)</label>
|
||||
<div class="mins-row">
|
||||
<input
|
||||
id="mins"
|
||||
v-model="form.reading_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
:disabled="!form.override_minutes"
|
||||
:placeholder="String(autoMinutes)"
|
||||
/>
|
||||
<label class="check">
|
||||
<input v-model="form.override_minutes" type="checkbox" />
|
||||
手动指定
|
||||
</label>
|
||||
</div>
|
||||
<p class="sub">自动估算:{{ finalMinutes }} 分钟</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>状态</label>
|
||||
<p class="value">
|
||||
<span class="dot" :class="form.status"></span>
|
||||
{{ form.status === 'published' ? '已发布' : '草稿' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn wide" @click="save()">立即保存</button>
|
||||
<RouterLink to="/admin" class="back">← 返回列表</RouterLink>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.state {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #9a5b45;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 22px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.kind-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.kind {
|
||||
padding: 5px 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kind.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.title-input {
|
||||
font-family: var(--serif);
|
||||
font-size: 19px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 7px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.tab.on {
|
||||
color: var(--accent);
|
||||
border-color: var(--line);
|
||||
background: var(--card);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.counter {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.md-input {
|
||||
min-height: 420px;
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
border-radius: 0 3px 3px 3px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-height: 420px;
|
||||
padding: 18px 20px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0 3px 3px 3px;
|
||||
}
|
||||
|
||||
.side {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
border-radius: 4px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.taglist {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.x {
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
padding: 0 0 0 4px;
|
||||
}
|
||||
|
||||
.mins-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
margin-right: 6px;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
|
||||
.dot.published {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-block;
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { adminApi, session } from '../api'
|
||||
import { site } from '../site'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
const data = await adminApi.login(username.value, password.value)
|
||||
session.user = username.value
|
||||
void data
|
||||
router.push('/admin')
|
||||
} catch (e) {
|
||||
error.value = '用户名或密码不对'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<p class="eyebrow">后台</p>
|
||||
<h1 class="title">登录 {{ site.site_title || 'ONE' }}</h1>
|
||||
|
||||
<div class="field">
|
||||
<label for="u">用户名</label>
|
||||
<input id="u" v-model="username" class="input" autocomplete="username" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="p">密码</label>
|
||||
<input
|
||||
id="p"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<button class="btn btn-primary" type="submit" :disabled="busy || !password">
|
||||
{{ busy ? '登录中…' : '登录' }}
|
||||
</button>
|
||||
|
||||
<p class="hint">
|
||||
默认账号在服务端环境变量里:<code>ONE_ADMIN_USER</code> / <code>ONE_ADMIN_PASSWORD</code>。
|
||||
</p>
|
||||
|
||||
<RouterLink to="/" class="back">← 回到前台</RouterLink>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
margin: 6px 0 22px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #9a5b45;
|
||||
font-size: 13px;
|
||||
margin: -6px 0 14px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 18px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.8;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hint code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--paper-sunken);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-block;
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { adminApi } from '../api'
|
||||
import { formatDateShort, relativeDate } from '../utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const status = ref('')
|
||||
const kind = ref('')
|
||||
const q = ref('')
|
||||
const page = ref(1)
|
||||
const size = 20
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params = { page: page.value, size }
|
||||
if (status.value) params.status = status.value
|
||||
if (kind.value) params.kind = kind.value
|
||||
if (q.value.trim()) params.q = q.value.trim()
|
||||
const data = await adminApi.posts(params)
|
||||
items.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch([status, kind, page], load)
|
||||
|
||||
let searchTimer
|
||||
function onSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
load()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
|
||||
|
||||
async function remove(id, title) {
|
||||
if (!window.confirm(`删除《${title}》?此操作不可撤销。`)) return
|
||||
try {
|
||||
await adminApi.deletePost(id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function edit(id) {
|
||||
router.push(`/admin/${id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header class="head">
|
||||
<h1 class="title">文章 <span class="count">{{ total }}</span></h1>
|
||||
<RouterLink to="/admin/new" class="btn btn-primary">写新的</RouterLink>
|
||||
</header>
|
||||
|
||||
<div class="filters">
|
||||
<input v-model="q" class="input search" placeholder="搜索标题 / 摘要 / 正文" @input="onSearch" />
|
||||
<select v-model="status" class="select">
|
||||
<option value="">全部状态</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="draft">草稿</option>
|
||||
</select>
|
||||
<select v-model="kind" class="select">
|
||||
<option value="">全部类型</option>
|
||||
<option value="long">长文</option>
|
||||
<option value="short">短文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!items.length" class="empty">
|
||||
没有匹配的文章。<RouterLink to="/admin/new">写一篇 →</RouterLink>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="c-title">标题</th>
|
||||
<th class="c-kind">类型</th>
|
||||
<th class="c-status">状态</th>
|
||||
<th class="c-date">发布时间</th>
|
||||
<th class="c-act"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in items" :key="p.id">
|
||||
<td class="c-title">
|
||||
<a href="#" class="name" @click.prevent="edit(p.id)">
|
||||
{{ p.title || '无题' }}
|
||||
</a>
|
||||
<span v-if="p.tags && p.tags.length" class="tags">
|
||||
<span v-for="t in p.tags" :key="t" class="tag-chip">{{ t }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="c-kind">
|
||||
<span class="badge" :class="{ short: p.kind === 'short' }">
|
||||
{{ p.kind === 'short' ? '短文' : '长文' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="c-status">
|
||||
<span class="dot" :class="p.status"></span>{{ p.status === 'published' ? '已发布' : '草稿' }}
|
||||
</td>
|
||||
<td class="c-date">
|
||||
<span :title="formatDateShort(p.published_at)">{{ relativeDate(p.published_at) }}</span>
|
||||
</td>
|
||||
<td class="c-act">
|
||||
<a href="#" @click.prevent="edit(p.id)">编辑</a>
|
||||
<a v-if="p.status === 'published'" :href="`/post/${p.slug}`" target="_blank">查看</a>
|
||||
<a href="#" class="danger" @click.prevent="remove(p.id, p.title)">删除</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<nav v-if="pageCount > 1" class="pager">
|
||||
<button class="btn" :disabled="page <= 1" @click="page = page - 1">← 上一页</button>
|
||||
<span class="page-info">第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<button class="btn" :disabled="page >= pageCount" @click="page = page + 1">下一页 →</button>
|
||||
</nav>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.select {
|
||||
width: auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.table tr:hover td {
|
||||
background: var(--paper-sunken);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge.short {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
margin-right: 6px;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
|
||||
.dot.published {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.c-act {
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.c-act a {
|
||||
margin-left: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.c-act a.danger {
|
||||
color: #9a5b45;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.c-kind,
|
||||
.c-date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { adminApi } from '../api'
|
||||
import { site } from '../site'
|
||||
|
||||
const form = reactive({
|
||||
site_title: '',
|
||||
site_desc: '',
|
||||
author_name: '',
|
||||
author_bio: '',
|
||||
footer_note: '',
|
||||
icp: '',
|
||||
posts_per_page: 10
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const ok = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
Object.assign(form, await adminApi.settings())
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
ok.value = ''
|
||||
try {
|
||||
const saved = await adminApi.saveSettings({ ...form })
|
||||
Object.assign(form, saved)
|
||||
Object.assign(site, saved)
|
||||
ok.value = '已保存'
|
||||
} catch (e) {
|
||||
error.value = e.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header class="head">
|
||||
<h1 class="title">站点设置</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
|
||||
<form v-else class="form" @submit.prevent="save">
|
||||
<div class="field">
|
||||
<label for="t">站点标题</label>
|
||||
<input id="t" v-model="form.site_title" class="input" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="d">站点简介</label>
|
||||
<input id="d" v-model="form.site_desc" class="input" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="a">作者名</label>
|
||||
<input id="a" v-model="form.author_name" class="input" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="b">作者简介</label>
|
||||
<textarea id="b" v-model="form.author_bio" class="textarea" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="f">页脚信息</label>
|
||||
<input id="f" v-model="form.footer_note" class="input" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="i">备案号(可留空)</label>
|
||||
<input id="i" v-model="form.icp" class="input" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="p">每页文章数</label>
|
||||
<input id="p" v-model.number="form.posts_per_page" type="number" min="1" max="100" class="input" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
|
||||
<button class="btn btn-primary" type="submit" :disabled="saving">
|
||||
{{ saving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.form {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #9a5b45;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '../api'
|
||||
|
||||
const tags = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const name = ref('')
|
||||
const editing = ref(null)
|
||||
const editName = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await adminApi.tags()
|
||||
tags.value = data.tags || []
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function create() {
|
||||
if (!name.value.trim()) return
|
||||
try {
|
||||
await adminApi.createTag(name.value.trim())
|
||||
name.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = e.message || '创建失败'
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(t) {
|
||||
editing.value = t.id
|
||||
editName.value = t.name
|
||||
}
|
||||
|
||||
async function saveEdit(id) {
|
||||
try {
|
||||
await adminApi.renameTag(id, editName.value)
|
||||
editing.value = null
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = e.message || '重命名失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(t) {
|
||||
if (!window.confirm(`删除标签「${t.name}」?文章上的关联会一起移除。`)) return
|
||||
try {
|
||||
await adminApi.deleteTag(t.id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = e.message || '删除失败'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<header class="head">
|
||||
<h1 class="title">标签</h1>
|
||||
</header>
|
||||
|
||||
<form class="new" @submit.prevent="create">
|
||||
<input v-model="name" class="input" placeholder="新标签名" />
|
||||
<button class="btn btn-primary" type="submit">添加</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="!tags.length" class="empty">还没有标签。</div>
|
||||
|
||||
<ul v-else class="list">
|
||||
<li v-for="t in tags" :key="t.id" class="row">
|
||||
<template v-if="editing === t.id">
|
||||
<input v-model="editName" class="input" @keyup.enter="saveEdit(t.id)" />
|
||||
<button class="btn" @click="saveEdit(t.id)">保存</button>
|
||||
<button class="btn" @click="editing = null">取消</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="name">{{ t.name }}</span>
|
||||
<code class="slug">/tag/{{ t.slug }}</code>
|
||||
<span class="count">{{ t.count }} 篇</span>
|
||||
<span class="act">
|
||||
<a href="#" @click.prevent="startEdit(t)">重命名</a>
|
||||
<a href="#" class="danger" @click.prevent="remove(t)">删除</a>
|
||||
</span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.new {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #9a5b45;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.slug {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.act {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.act a {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.act a.danger {
|
||||
color: #9a5b45;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.slug {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { adminApi, session } from '../api'
|
||||
|
||||
// 后台的登录态在服务端(httpOnly cookie);这里只是探测一次是否还有效
|
||||
export async function checkAuth() {
|
||||
try {
|
||||
await adminApi.me()
|
||||
return true
|
||||
} catch (e) {
|
||||
session.clear()
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
const base = ''
|
||||
|
||||
async function request(path, { method = 'GET', body, auth = false } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
})
|
||||
if (res.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('one:unauthorized'))
|
||||
const err = new Error('未登录或登录已过期')
|
||||
err.status = 401
|
||||
throw err
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `请求失败(${res.status})`
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data && data.error) msg = data.error
|
||||
} catch (_) {
|
||||
/* 非 JSON 响应 */
|
||||
}
|
||||
const err = new Error(msg)
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const publicApi = {
|
||||
site: () => request('/api/site'),
|
||||
posts: (params = {}) => request('/api/posts?' + new URLSearchParams(params)),
|
||||
post: (slug) => request('/api/posts/' + encodeURIComponent(slug)),
|
||||
archive: () => request('/api/archive'),
|
||||
tags: () => request('/api/tags')
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
login: (username, password) =>
|
||||
request('/api/admin/login', { method: 'POST', body: { username, password } }),
|
||||
logout: () => request('/api/admin/logout', { method: 'POST' }),
|
||||
me: () => request('/api/admin/me'),
|
||||
posts: (params = {}) => request('/api/admin/posts?' + new URLSearchParams(params)),
|
||||
post: (id) => request('/api/admin/posts/' + id),
|
||||
createPost: (body) => request('/api/admin/posts', { method: 'POST', body }),
|
||||
updatePost: (id, body) => request('/api/admin/posts/' + id, { method: 'PUT', body }),
|
||||
deletePost: (id) => request('/api/admin/posts/' + id, { method: 'DELETE' }),
|
||||
tags: () => request('/api/admin/tags'),
|
||||
createTag: (name) => request('/api/admin/tags', { method: 'POST', body: { name } }),
|
||||
renameTag: (id, name) => request('/api/admin/tags/' + id, { method: 'PUT', body: { name } }),
|
||||
deleteTag: (id) => request('/api/admin/tags/' + id, { method: 'DELETE' }),
|
||||
settings: () => request('/api/admin/settings'),
|
||||
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body })
|
||||
}
|
||||
|
||||
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名
|
||||
const USER_KEY = 'one.admin.user'
|
||||
|
||||
export const session = {
|
||||
get user() {
|
||||
return localStorage.getItem(USER_KEY) || ''
|
||||
},
|
||||
set user(v) {
|
||||
if (v) localStorage.setItem(USER_KEY, v)
|
||||
else localStorage.removeItem(USER_KEY)
|
||||
},
|
||||
clear() {
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup>
|
||||
import { site } from '../site'
|
||||
import { publicApi } from '../api'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const tags = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await publicApi.tags()
|
||||
tags.value = (data.tags || []).slice(0, 8)
|
||||
} catch (e) {
|
||||
tags.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rail-left">
|
||||
<div class="inner">
|
||||
<RouterLink to="/" class="brand">
|
||||
<span class="mark">○</span>
|
||||
<span class="name">{{ site.site_title || 'ONE' }}</span>
|
||||
</RouterLink>
|
||||
<p class="desc">{{ site.site_desc }}</p>
|
||||
|
||||
<nav class="nav">
|
||||
<RouterLink to="/" class="item">
|
||||
<span class="ico">◴</span><span>时间线</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/archive" class="item">
|
||||
<span class="ico">▤</span><span>归档</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/tags" class="item">
|
||||
<span class="ico">#</span><span>标签</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/about" class="item">
|
||||
<span class="ico">◍</span><span>关于</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<a class="subscribe" href="/rss.xml">订阅 RSS</a>
|
||||
|
||||
<div v-if="tags.length" class="tagbox">
|
||||
<p class="eyebrow">常读标签</p>
|
||||
<div class="tags">
|
||||
<RouterLink v-for="t in tags" :key="t.id" :to="`/tag/${t.slug}`" class="tag-chip">
|
||||
{{ t.name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rail-left {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
padding: 28px 0 40px;
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: var(--serif);
|
||||
font-size: 21px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: -6px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
margin-left: -10px;
|
||||
border-radius: 3px;
|
||||
font-size: 15px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--paper-sunken);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.item.router-link-exact-active {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
color: var(--faint);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.item.router-link-exact-active .ico {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subscribe {
|
||||
align-self: flex-start;
|
||||
padding: 7px 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.subscribe:hover {
|
||||
background: #356f88;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tagbox {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { site } from '../site'
|
||||
import { relativeDate, stripTags, minutesLabel } from '../utils'
|
||||
|
||||
const props = defineProps({
|
||||
post: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const isShort = computed(() => props.post.kind === 'short')
|
||||
|
||||
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
|
||||
const body = computed(() => {
|
||||
if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '')
|
||||
return props.post.content_html || ''
|
||||
})
|
||||
|
||||
// 长文没写摘要时,从正文里截一段纯文本
|
||||
const summaryText = computed(() => {
|
||||
const text = props.post.summary || stripTags(props.post.content_html || '')
|
||||
return text.length > 110 ? text.slice(0, 110) + '…' : text
|
||||
})
|
||||
|
||||
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="row-feed" :class="{ short: isShort }">
|
||||
<div class="avatar" aria-hidden="true">{{ initial }}</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="byline">
|
||||
<span class="author">{{ site.author_name || 'ONE' }}</span>
|
||||
<span class="handle">@oneblog</span>
|
||||
<span class="dot">·</span>
|
||||
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
|
||||
<span v-if="isShort" class="kind">短文</span>
|
||||
<span v-if="post.status === 'draft'" class="kind draft">草稿</span>
|
||||
</div>
|
||||
|
||||
<RouterLink v-if="!isShort" :to="`/post/${post.slug}`" class="title-link">
|
||||
<h2 class="title">{{ post.title || '无题' }}</h2>
|
||||
</RouterLink>
|
||||
|
||||
<!-- 长文:摘要;短文:正文直接铺开 -->
|
||||
<div v-if="isShort" class="prose short-body" v-html="body"></div>
|
||||
<div v-else class="summary">{{ summaryText }}</div>
|
||||
|
||||
<div class="meta">
|
||||
<RouterLink :to="`/post/${post.slug}`" class="read">
|
||||
{{ isShort ? '查看' : '继续阅读' }} →
|
||||
</RouterLink>
|
||||
<span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span>
|
||||
<span v-if="post.tags && post.tags.length" class="tags">
|
||||
<RouterLink
|
||||
v-for="t in post.tags"
|
||||
:key="t"
|
||||
:to="`/tag/${encodeURIComponent(t)}`"
|
||||
class="tag-chip"
|
||||
>
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.row-feed {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 18px 16px;
|
||||
margin: 0 -16px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.row-feed:hover {
|
||||
background: var(--paper-sunken);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--serif);
|
||||
font-size: 17px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.byline {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.author {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.handle,
|
||||
.dot {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.kind {
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kind.draft {
|
||||
color: #9a5b45;
|
||||
border-color: #ddcdc2;
|
||||
}
|
||||
|
||||
.title-link {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 21px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: var(--ink-soft);
|
||||
font-size: 15px;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.short-body {
|
||||
margin-top: 6px;
|
||||
font-size: 16.5px;
|
||||
}
|
||||
|
||||
.short-body :deep(p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.read {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.row-feed {
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
padding: 16px 12px;
|
||||
margin: 0 -12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 19px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { publicApi } from '../api'
|
||||
import { site } from '../site'
|
||||
import { relativeDate } from '../utils'
|
||||
|
||||
const latest = ref([])
|
||||
const tags = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [posts, tagData] = await Promise.all([
|
||||
publicApi.posts({ size: 5 }),
|
||||
publicApi.tags()
|
||||
])
|
||||
latest.value = posts.items || []
|
||||
tags.value = (tagData.tags || []).slice(0, 12)
|
||||
} catch (e) {
|
||||
latest.value = []
|
||||
tags.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rail-right">
|
||||
<section class="card">
|
||||
<p class="eyebrow">关于这里</p>
|
||||
<p class="bio">{{ site.author_bio || site.site_desc }}</p>
|
||||
<RouterLink to="/about" class="more">了解更多 →</RouterLink>
|
||||
</section>
|
||||
|
||||
<section v-if="latest.length" class="card">
|
||||
<p class="eyebrow">最近更新</p>
|
||||
<ul class="list">
|
||||
<li v-for="p in latest" :key="p.id">
|
||||
<RouterLink :to="`/post/${p.slug}`" class="row">
|
||||
<span class="title">
|
||||
{{ p.kind === 'short' ? '短文' : p.title || '无题' }}
|
||||
<span v-if="p.kind === 'short'" class="badge">短</span>
|
||||
</span>
|
||||
<span class="date">{{ relativeDate(p.published_at) }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="tags.length" class="card">
|
||||
<p class="eyebrow">标签</p>
|
||||
<div class="tags">
|
||||
<RouterLink v-for="t in tags" :key="t.id" :to="`/tag/${t.slug}`" class="tag-chip">
|
||||
{{ t.name }}<span class="count"> {{ t.count }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<p class="eyebrow">订阅</p>
|
||||
<p class="bio">用 RSS 阅读器跟进更新。</p>
|
||||
<a class="pill pill-ghost" href="/rss.xml">RSS / feed.xml</a>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rail-right {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
padding: 28px 0 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
background: var(--card);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.bio {
|
||||
margin: 8px 0 10px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.85;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: block;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px dashed var(--line-soft);
|
||||
}
|
||||
|
||||
.list li:last-child .row {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { site } from '../site'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 窄屏时左栏收起,导航回到顶部 -->
|
||||
<header class="topbar">
|
||||
<RouterLink to="/" class="brand">{{ site.site_title || 'ONE' }}</RouterLink>
|
||||
<nav class="links">
|
||||
<RouterLink to="/">首页</RouterLink>
|
||||
<RouterLink to="/archive">归档</RouterLink>
|
||||
<RouterLink to="/tags">标签</RouterLink>
|
||||
<RouterLink to="/about">关于</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
padding: 10px 18px;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: var(--serif);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.links a.router-link-exact-active {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ src?: string; name?: string; size?: number }>(),
|
||||
{ size: 40, name: '' },
|
||||
)
|
||||
|
||||
const failed = ref(false)
|
||||
const initial = computed(() => (props.name || '?').slice(0, 1).toUpperCase())
|
||||
const sizeStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-block rounded-full overflow-hidden shrink-0 bg-zinc-100" :style="sizeStyle">
|
||||
<img
|
||||
v-if="src && !failed"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
class="size-full object-cover"
|
||||
loading="lazy"
|
||||
@error="failed = true"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="size-full flex items-center justify-center bg-accent text-white font-medium select-none"
|
||||
:style="{ fontSize: `${Math.round(size * 0.42)}px` }"
|
||||
>
|
||||
{{ initial }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { dark, toggle } = useTheme()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-zinc-500 hover:text-accent transition-colors"
|
||||
:aria-label="dark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="toggle"
|
||||
>
|
||||
<i :class="dark ? 'i-mingcute-moon-line' : 'i-mingcute-sun-line'" class="text-xl" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{ icon?: string; text?: string }>(),
|
||||
{
|
||||
icon: 'i-mingcute-inbox-line',
|
||||
text: 'No posts yet',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-zinc-400">
|
||||
<i :class="icon" class="text-6xl mb-4" />
|
||||
<span>{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ value: number }>()
|
||||
|
||||
const text = computed(() => formatNumber(props.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>{{ text }}</span>
|
||||
</template>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const locales = ['en', 'zh'] as const
|
||||
type Locale = (typeof locales)[number]
|
||||
|
||||
const locale = ref<Locale>('en')
|
||||
|
||||
function toggle() {
|
||||
locale.value = locale.value === 'en' ? 'zh' : 'en'
|
||||
document.documentElement.lang = locale.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-zinc-500 hover:text-accent transition-colors"
|
||||
aria-label="Switch language"
|
||||
@click="toggle"
|
||||
>
|
||||
<i class="i-mingcute-translate-2-line text-xl" />
|
||||
<span class="ml-1 text-xs uppercase">{{ locale }}</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,10 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ text?: string }>(), { text: 'Loading...' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-zinc-400 space-y-3">
|
||||
<i class="i-mingcute-loading-3-line text-3xl animate-spin" />
|
||||
<span class="text-sm">{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// xLog Logo:内联 SVG,保留原始渐变与暗色适配
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ size?: number }>(), { size: 36 })
|
||||
|
||||
const gradientId = `xlog-gradient-${Math.random().toString(36).slice(2, 8)}`
|
||||
const style = computed(() => ({ width: `${props.size}px`, height: `${props.size}px` }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:style="style"
|
||||
viewBox="0 0 128.81 128.17"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
:id="gradientId"
|
||||
x1="57.54"
|
||||
y1="31.44"
|
||||
x2="128.81"
|
||||
y2="31.44"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stop-color="#ff4d4d" />
|
||||
<stop offset=".99" stop-color="#f9cb28" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
class="xlog-logo-dark"
|
||||
d="M56.32,100.47c0,15.98-11.84,27.61-27.85,27.61S.33,116.45,.33,100.47s11.83-27.45,28.08-27.45,27.91,11.63,27.91,27.45Z"
|
||||
/>
|
||||
<path
|
||||
class="xlog-logo-dark"
|
||||
d="M120,101.26v19.31c-4.87,4.63-12.9,7.6-21.65,7.6-18.33,0-30-10.49-30-26.7s12.92-28.34,31.07-28.34v28.13h20.58Z"
|
||||
/>
|
||||
<polygon
|
||||
:fill="`url(#${gradientId})`"
|
||||
points="119.7 21.48 128.81 38.51 83.27 62.88 57.54 14.79 85.17 0 101.79 31.06 119.7 21.48"
|
||||
/>
|
||||
<polygon
|
||||
class="xlog-logo-dark"
|
||||
points="0 63.11 16.54 32.82 .68 8.17 55.98 8.17 40.12 32.82 56.65 63.11 0 63.11"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.xlog-logo-dark {
|
||||
fill: #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.xlog-logo-dark {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// Markdown 渲染:marked 解析 + DOMPurify 消毒,输出到 .prose 容器
|
||||
import { computed } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const props = defineProps<{ content: string }>()
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true })
|
||||
|
||||
const html = computed(() => {
|
||||
const raw = marked.parse(props.content, { async: false }) as string
|
||||
return DOMPurify.sanitize(raw)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose" v-html="html" />
|
||||
</template>
|
||||
@@ -1,91 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 文章卡片:整体为一个链接,封面 + 标题/摘要 + 元信息 + 作者
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { Post } from '@/mock/data'
|
||||
import { estimateReadingTime } from '@/lib/utils'
|
||||
|
||||
import Avatar from './Avatar.vue'
|
||||
import FormattedNumber from './FormattedNumber.vue'
|
||||
import Time from './Time.vue'
|
||||
|
||||
const props = defineProps<{ post: Post; isShort?: boolean }>()
|
||||
|
||||
const readingTime = computed(() => estimateReadingTime(props.post.content))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/post/${post.siteId}/${post.slug}`"
|
||||
class="xlog-post rounded-2xl flex flex-col items-center group relative border sm:hover:bg-hover transition-all hover:opacity-100"
|
||||
>
|
||||
<!-- 置顶标记 -->
|
||||
<span
|
||||
v-if="post.pinned"
|
||||
class="absolute top-2 right-2 z-10 text-xs border transition-colors text-zinc-500 inline-flex items-center bg-zinc-100 rounded-full px-2 py-[1.5px]"
|
||||
>
|
||||
<i class="i-mingcute-pin-2-fill mr-1" />
|
||||
Pinned
|
||||
</span>
|
||||
|
||||
<!-- 封面图 -->
|
||||
<div class="xlog-post-cover rounded-t-2xl overflow-hidden flex items-center relative w-full aspect-video border-b">
|
||||
<img
|
||||
:src="post.cover"
|
||||
:alt="post.title"
|
||||
class="object-cover size-full sm:group-hover:scale-105 sm:transition-transform sm:duration-400 sm:ease-in-out bg-white"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="px-3 py-2 w-full min-w-0 flex flex-col text-sm space-y-2 sm:px-5 sm:py-4 h-auto sm:h-[163px]">
|
||||
<!-- 标题 + 摘要 -->
|
||||
<div class="space-y-2 line-clamp-3 h-[75px]">
|
||||
<h2 class="xlog-post-title font-bold text-zinc-700 text-base">
|
||||
{{ post.title }}
|
||||
</h2>
|
||||
<div class="xlog-post-excerpt text-zinc-500 line-clamp-3" style="word-break: break-word">
|
||||
{{ post.excerpt }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部元信息 -->
|
||||
<div class="xlog-post-meta text-zinc-400 flex items-center text-[13px] truncate space-x-2">
|
||||
<span
|
||||
v-if="post.tags[0]"
|
||||
class="hover:text-zinc-600 hover:bg-zinc-200 border transition-colors text-zinc-500 inline-flex items-center bg-zinc-100 rounded-full px-2 py-[1.5px] truncate text-xs sm:text-[13px] h-5"
|
||||
>
|
||||
<i class="i-mingcute-tag-line mr-[2px]" />
|
||||
{{ post.tags[0] }}
|
||||
</span>
|
||||
<span class="xlog-post-views inline-flex items-center">
|
||||
<i class="i-mingcute-eye-line mr-[2px] text-base" />
|
||||
<FormattedNumber :value="post.views" />
|
||||
</span>
|
||||
<span class="xlog-post-views inline-flex items-center">
|
||||
<i class="i-mingcute-comment-line mr-[2px] text-base" />
|
||||
<FormattedNumber :value="post.comments" />
|
||||
</span>
|
||||
<span class="xlog-post-word-count sm:inline-flex items-center hidden">
|
||||
<i class="i-mingcute-sandglass-line mr-[2px] text-sm" />
|
||||
<span style="word-spacing: -.2ch">{{ readingTime }} min</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 作者 + 时间 -->
|
||||
<div class="flex items-center space-x-1 text-xs sm:text-sm overflow-hidden">
|
||||
<span class="flex items-center cursor-pointer">
|
||||
<span class="size-5 inline-block mr-[6px]">
|
||||
<Avatar :src="post.author.avatar" :name="post.author.name" :size="20" />
|
||||
</span>
|
||||
<span class="font-medium truncate text-zinc-600">{{ post.author.name }}</span>
|
||||
</span>
|
||||
<span class="text-zinc-400 hidden sm:inline-block">·</span>
|
||||
<time class="xlog-post-date whitespace-nowrap text-zinc-400 hidden sm:inline-block">
|
||||
<Time :date="post.date" />
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 文章卡片骨架屏(配合 HomeFeed 的 grid 使用)
|
||||
withDefaults(defineProps<{ count?: number }>(), { count: 6 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-3 my-8">
|
||||
<div
|
||||
v-for="i in count"
|
||||
:key="i"
|
||||
class="rounded-2xl border animate-pulse"
|
||||
>
|
||||
<!-- 封面图骨架 -->
|
||||
<div class="h-auto rounded-t-2xl rounded-b-none w-full aspect-video border-b bg-gray-100" />
|
||||
<!-- 内容骨架 -->
|
||||
<div class="rounded-t-none rounded-b-2xl p-3 pt-2 sm:p-5 sm:pt-4 h-[168px] sm:h-[204px]">
|
||||
<div class="flex items-center space-x-1 sm:space-x-2 mb-2 sm:mb-4 text-xs sm:text-sm">
|
||||
<span class="flex items-center space-x-1 sm:space-x-2">
|
||||
<span class="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-gray-100 block" />
|
||||
<span class="w-[120px] h-5 bg-gray-100 block rounded" />
|
||||
</span>
|
||||
</div>
|
||||
<span class="w-full h-28 bg-gray-100 block rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatTime } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ date: string | Date }>()
|
||||
|
||||
const text = computed(() => formatTime(props.date))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<time :datetime="typeof date === 'string' ? date : date.toISOString()">{{ text }}</time>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘主内容容器
|
||||
defineProps<{ title?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-[270px] relative p-5 md:px-10 md:py-8 min-h-full flex flex-col bg-white"
|
||||
id="dashboard-main"
|
||||
>
|
||||
<h2 v-if="title" class="text-2xl font-bold mb-8">{{ title }}</h2>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,108 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘侧边栏:Logo + 连接钱包 + 导航 + 底部帮助链接
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const links = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
to: '/dashboard',
|
||||
icon: 'i-mingcute-grid-line',
|
||||
level: 0,
|
||||
},
|
||||
{
|
||||
key: 'posts',
|
||||
label: 'Posts',
|
||||
to: '/dashboard/posts',
|
||||
icon: 'i-mingcute-news-line',
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
key: 'pages',
|
||||
label: 'Pages',
|
||||
to: '/dashboard/pages',
|
||||
icon: 'i-mingcute-file-line',
|
||||
level: 1,
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'Settings',
|
||||
to: '/dashboard/settings/general',
|
||||
icon: 'i-mingcute-settings-3-line',
|
||||
level: 1,
|
||||
},
|
||||
]
|
||||
|
||||
function isActive(link: { key: string }) {
|
||||
switch (link.key) {
|
||||
case 'dashboard':
|
||||
return route.path === '/dashboard'
|
||||
case 'posts':
|
||||
return route.path.startsWith('/dashboard/posts')
|
||||
case 'pages':
|
||||
return route.path.startsWith('/dashboard/pages')
|
||||
case 'settings':
|
||||
return route.path.startsWith('/dashboard/settings')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-sidebar fixed h-full flex flex-col bg-slate-50">
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" class="mb-2 px-5 pt-3 pb-2 text-2xl font-extrabold flex items-center">
|
||||
<div class="inline-block size-9 mr-3">
|
||||
<Logo :size="36" />
|
||||
</div>
|
||||
MyBlog
|
||||
</RouterLink>
|
||||
|
||||
<!-- 连接钱包 -->
|
||||
<div class="mb-2 px-2 pt-3 pb-2">
|
||||
<button class="button is-primary is-block">Connect Wallet</button>
|
||||
</div>
|
||||
|
||||
<!-- 导航链接 -->
|
||||
<div class="px-3 space-y-[2px] text-zinc-500 flex-1 min-h-0 overflow-y-auto">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link.key"
|
||||
:to="link.to"
|
||||
class="flex px-4 h-12 items-center rounded-xl space-x-2 w-full transition-colors"
|
||||
:class="
|
||||
isActive(link)
|
||||
? 'bg-white font-medium text-accent drop-shadow-sm'
|
||||
: 'hover:bg-slate-200/50'
|
||||
"
|
||||
:style="{ marginLeft: `${link.level * 20}px` }"
|
||||
>
|
||||
<i :class="link.icon" class="text-xl" />
|
||||
<span class="truncate">{{ link.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- 底部固定按钮 -->
|
||||
<div class="flex items-center px-4 flex-col pb-4">
|
||||
<a
|
||||
href="#"
|
||||
class="space-x-1 text-zinc-500 hover:text-zinc-800 flex w-full h-12 items-center justify-center transition-colors mb-2"
|
||||
>
|
||||
<i class="i-mingcute-question-line text-lg" />
|
||||
<span>Need help?</span>
|
||||
</a>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="space-x-2 border rounded-lg border-slate-200 text-accent hover:scale-105 transition-transform flex w-full h-12 items-center justify-center bg-white drop-shadow-sm"
|
||||
>
|
||||
<span class="i-mingcute-home-1-line" />
|
||||
<span>View Site</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘移动端顶栏:汉堡菜单 + Logo
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full top-0 h-16 bg-slate-50 z-20 transition-all flex flex-row fixed px-5 md:px-10 items-center lg:hidden"
|
||||
>
|
||||
<button class="mr-3" aria-label="Toggle menu" @click="emit('toggle')">
|
||||
<i class="i-mingcute-menu-line text-2xl text-zinc-500" />
|
||||
</button>
|
||||
<RouterLink to="/" class="text-xl font-extrabold flex items-center">
|
||||
<div class="inline-block size-8 mr-2">
|
||||
<Logo :size="30" />
|
||||
</div>
|
||||
MyBlog
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,154 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 文章/页面管理列表:头部操作 + 状态筛选 + 列表项
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import FormattedNumber from '@/components/common/FormattedNumber.vue'
|
||||
import Tabs from '@/components/ui/Tabs.vue'
|
||||
import type { Post } from '@/mock/data'
|
||||
import { allPosts } from '@/mock/data'
|
||||
import { estimateReadingTime } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ title?: string; itemLabel?: string }>(),
|
||||
{ title: 'Posts', itemLabel: 'Post' },
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
{ key: 'all', label: 'All Posts' },
|
||||
{ key: 'published', label: 'Published' },
|
||||
{ key: 'draft', label: 'Draft' },
|
||||
{ key: 'scheduled', label: 'Scheduled' },
|
||||
]
|
||||
|
||||
const filter = ref('all')
|
||||
const list = ref<Post[]>([...allPosts])
|
||||
const menuOpenFor = ref<string | null>(null)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (filter.value === 'all') return list.value
|
||||
return list.value.filter((p) => (p.status ?? 'published') === filter.value)
|
||||
})
|
||||
|
||||
function statusText(status?: Post['status']) {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'Draft'
|
||||
case 'scheduled':
|
||||
return 'Scheduled'
|
||||
default:
|
||||
return 'Published'
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
list.value = list.value.filter((p) => p.id !== id)
|
||||
menuOpenFor.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-lg">
|
||||
<!-- 头部 -->
|
||||
<header class="mb-4 space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold">{{ title }}</h2>
|
||||
</div>
|
||||
<div class="space-x-4">
|
||||
<RouterLink to="/dashboard/editor" class="button is-primary space-x-2 inline-flex">
|
||||
<i class="i-mingcute-add-line inline-block" />
|
||||
<span>New {{ itemLabel }}</span>
|
||||
</RouterLink>
|
||||
<button class="button is-secondary space-x-2">
|
||||
<i class="i-mingcute-file-import-line inline-block" />
|
||||
<span>Import</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 筛选标签 -->
|
||||
<Tabs :items="tabs" :active-key="filter" @select="filter = $event" />
|
||||
|
||||
<!-- 文章列表 -->
|
||||
<div class="space-y-0">
|
||||
<RouterLink
|
||||
v-for="post in filtered"
|
||||
:key="post.id"
|
||||
:to="`/dashboard/editor?id=${post.id}`"
|
||||
class="group relative hover:bg-zinc-100 rounded-lg py-4 px-3 transition-colors -mx-3 flex max-sm:flex-col gap-4"
|
||||
>
|
||||
<!-- 封面图 -->
|
||||
<div class="rounded-lg sm:w-48 overflow-hidden shrink-0">
|
||||
<img
|
||||
class="w-full aspect-video object-cover"
|
||||
:src="post.cover"
|
||||
:alt="post.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 信息区 -->
|
||||
<div class="min-w-0 flex-1 flex flex-col justify-between">
|
||||
<div class="xlog-post-title font-bold text-base text-zinc-700">
|
||||
<span>{{ post.title }}</span>
|
||||
</div>
|
||||
<div class="xlog-post-excerpt text-zinc-500 line-clamp-1 text-sm">
|
||||
{{ post.excerpt }}
|
||||
</div>
|
||||
<div
|
||||
class="xlog-post-meta text-zinc-400 flex items-center text-[13px] h-[26px] truncate"
|
||||
>
|
||||
<span
|
||||
v-if="post.tags[0]"
|
||||
class="border transition-colors text-zinc-500 inline-flex items-center bg-zinc-100 rounded-full px-2 py-[1.5px] truncate text-xs mr-2"
|
||||
>
|
||||
<i class="i-mingcute-tag-line mr-[2px]" />
|
||||
{{ post.tags[0] }}
|
||||
</span>
|
||||
<span class="xlog-post-word-count sm:inline-flex items-center hidden mr-2">
|
||||
<i class="i-mingcute-time-line mr-[2px]" />
|
||||
<span style="word-spacing: -.2ch">{{ estimateReadingTime(post.content) }} min</span>
|
||||
</span>
|
||||
<span class="xlog-post-views inline-flex items-center">
|
||||
<i class="i-mingcute-eye-line mr-[2px]" />
|
||||
<span><FormattedNumber :value="post.views" /></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-zinc-400 text-sm">
|
||||
<span class="capitalize">{{ statusText(post.status) }}</span>
|
||||
<span class="mx-2">·</span>
|
||||
<span>{{ post.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="shrink-0 flex gap-2 sm:self-center sm:ml-auto relative">
|
||||
<button
|
||||
class="text-gray-400 size-8 rounded inline-flex hover:bg-gray-200 justify-center items-center"
|
||||
aria-label="More actions"
|
||||
@click.prevent="menuOpenFor = menuOpenFor === post.id ? null : post.id"
|
||||
>
|
||||
<i class="i-mingcute-more-1-line text-2xl" />
|
||||
</button>
|
||||
<!-- 操作菜单 -->
|
||||
<div
|
||||
v-if="menuOpenFor === post.id"
|
||||
class="absolute right-0 top-full mt-1 z-10 bg-white border rounded-xl shadow-modal p-1 min-w-[140px]"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="flex w-full px-3 py-2 text-sm text-zinc-600 hover:bg-zinc-100 rounded-lg items-center"
|
||||
@click="remove(post.id)"
|
||||
>
|
||||
<i class="i-mingcute-delete-2-line mr-2 text-[#f91880]" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<EmptyState v-if="!filtered.length" text="No posts yet" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Tabs from '@/components/ui/Tabs.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const items = [
|
||||
{ key: 'featured', label: 'Featured', to: '/' },
|
||||
{ key: 'shorts', label: 'Shorts', to: '/shorts' },
|
||||
{ key: 'latest', label: 'Latest', to: '/latest' },
|
||||
{ key: 'hottest', label: 'Hottest', to: '/hottest' },
|
||||
{ key: 'following', label: 'Following', to: '/following' },
|
||||
]
|
||||
|
||||
const activeKey = computed(
|
||||
() => items.find((item) => item.to === route.path)?.key ?? 'featured',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs :items="items" :active-key="activeKey" class="border-none" />
|
||||
</template>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 主页文章流:按类型过滤/排序,含 AI 过滤开关(仅演示交互)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import PostCard from '@/components/common/PostCard.vue'
|
||||
import Skeleton from '@/components/common/Skeleton.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
type FeedType = 'featured' | 'shorts' | 'latest' | 'hottest' | 'following'
|
||||
|
||||
const props = withDefaults(defineProps<{ type?: FeedType }>(), { type: 'featured' })
|
||||
|
||||
const loading = ref(false)
|
||||
const ai = ref(true)
|
||||
|
||||
const posts = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'latest':
|
||||
return [...allPosts].sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||
)
|
||||
case 'hottest':
|
||||
return [...allPosts].sort((a, b) => b.views - a.views)
|
||||
case 'featured': {
|
||||
const featured = allPosts.filter((p) => p.featured)
|
||||
return featured.length ? featured : allPosts
|
||||
}
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<!-- AI 过滤开关 -->
|
||||
<div class="flex items-center text-zinc-500">
|
||||
<i class="i-mingcute-sparkles-line mr-2 text-lg" />
|
||||
<span class="text-sm">AI filter</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle AI filter"
|
||||
class="ml-5 relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
:class="ai ? 'bg-accent' : 'bg-gray-200'"
|
||||
@click="ai = !ai"
|
||||
>
|
||||
<span
|
||||
class="inline-block size-4 rounded-full bg-white transition"
|
||||
:class="ai ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<Skeleton v-if="loading" />
|
||||
<div v-else-if="posts.length" class="xlog-posts my-8 min-h-[1177px]">
|
||||
<div class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-3">
|
||||
<PostCard v-for="post in posts" :key="post.id" :post="post" />
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else text="No posts yet" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 主页右侧边栏:推广链接 + 搜索 + 推荐创作者 + CSB 领取
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import { sites } from '@/mock/data'
|
||||
|
||||
import SearchInput from './SearchInput.vue'
|
||||
|
||||
const creators = sites
|
||||
const claimed = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="w-80 pl-10 hidden lg:block space-y-10">
|
||||
<!-- 推广链接 -->
|
||||
<div class="space-y-5">
|
||||
<RouterLink to="/about" class="flex items-center text-zinc-500 hover:text-accent">
|
||||
<i class="i-mingcute-information-line mr-2" />
|
||||
About
|
||||
</RouterLink>
|
||||
<a href="#" class="flex items-center text-zinc-500 hover:text-accent">
|
||||
<i class="i-mingcute-question-line mr-2" />
|
||||
Help
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<SearchInput />
|
||||
|
||||
<!-- 推荐创作者 -->
|
||||
<div class="text-center text-zinc-700 space-y-3">
|
||||
<p class="font-bold text-lg">Suggested creators for you</p>
|
||||
<ul class="space-y-3">
|
||||
<li v-for="creator in creators" :key="creator.handle" class="flex align-middle">
|
||||
<RouterLink class="inline-flex align-middle w-full" :to="`/site/${creator.handle}`">
|
||||
<span class="size-10 inline-block">
|
||||
<Avatar :src="creator.avatar" :name="creator.name" :size="40" />
|
||||
</span>
|
||||
<span class="ml-3 min-w-0 flex-1 justify-center inline-flex flex-col">
|
||||
<span class="truncate w-full inline-block font-medium">{{ creator.name }}</span>
|
||||
<span class="text-gray-500 text-xs truncate w-full inline-block mt-1">
|
||||
{{ creator.description }}
|
||||
</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- CSB 领取 -->
|
||||
<div class="text-center text-zinc-700 space-y-3">
|
||||
<p class="font-bold text-lg">Need More CSB?</p>
|
||||
<button class="button is-primary is-block" @click="claimed = true">
|
||||
{{ claimed ? 'Claimed ✓' : 'Claim CSB' }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const tabs = [
|
||||
{ key: 'home', label: 'Home', to: '/' },
|
||||
{ key: 'about', label: 'About', to: '/about' },
|
||||
{ key: 'github', label: 'GitHub Stars', to: 'https://github.com/Crossbell-Box/xLog' },
|
||||
]
|
||||
|
||||
function isActive(key: string, to: string) {
|
||||
if (to.startsWith('http')) return false
|
||||
return route.path === to
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-x-14 text-zinc-500 flex">
|
||||
<a
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:href="tab.to"
|
||||
class="hover:text-accent text-lg"
|
||||
:class="{ 'text-accent': isActive(tab.key, tab.to) }"
|
||||
:target="tab.to.startsWith('http') ? '_blank' : undefined"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 底部推广链接(RSS / GitHub / Discord / Twitter)
|
||||
const links = [
|
||||
{
|
||||
icon: 'i-mingcute-rss-2-fill text-2xl',
|
||||
color: 'text-[#ee832f]',
|
||||
href: '/rss.xml',
|
||||
label: 'RSS',
|
||||
},
|
||||
{
|
||||
icon: 'i-mingcute-github-fill text-2xl',
|
||||
color: 'text-[#181717] dark:text-[#e6edf3]',
|
||||
href: 'https://github.com/Crossbell-Box/xLog',
|
||||
label: 'GitHub',
|
||||
},
|
||||
{
|
||||
icon: 'i-mingcute-discord-fill text-2xl',
|
||||
color: 'text-[#7289da]',
|
||||
href: 'https://discord.gg/xLog',
|
||||
label: 'Discord',
|
||||
},
|
||||
{
|
||||
icon: 'i-mingcute-twitter-fill text-2xl',
|
||||
color: 'text-[#1DA1F2]',
|
||||
href: 'https://twitter.com/xLog',
|
||||
label: 'Twitter',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<a
|
||||
v-for="link in links"
|
||||
:key="link.label"
|
||||
:href="link.href"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="flex-1 flex items-center justify-center hover:opacity-80"
|
||||
:class="link.color"
|
||||
:aria-label="link.label"
|
||||
>
|
||||
<i :class="link.icon" />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const query = ref('')
|
||||
|
||||
function submit() {
|
||||
if (!query.value.trim()) return
|
||||
router.push(`/search?q=${encodeURIComponent(query.value.trim())}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative xlog-search-input">
|
||||
<i class="i-mingcute-search-line absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
v-model="query"
|
||||
class="input is-block pl-9"
|
||||
placeholder="Search"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,137 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 文章互动区(点赞/打赏/分享)+ 评论区
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import Time from '@/components/common/Time.vue'
|
||||
import type { Comment, Post } from '@/mock/data'
|
||||
import { comments } from '@/mock/data'
|
||||
|
||||
const props = defineProps<{ post: Post }>()
|
||||
|
||||
// 点赞
|
||||
const liked = ref(false)
|
||||
const likeCount = ref(props.post.likes)
|
||||
function toggleLike() {
|
||||
liked.value = !liked.value
|
||||
likeCount.value += liked.value ? 1 : -1
|
||||
}
|
||||
|
||||
// 打赏
|
||||
const tipped = ref(false)
|
||||
|
||||
// 分享(复制链接)
|
||||
const shared = ref(false)
|
||||
async function share() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
} catch {
|
||||
/* ignore clipboard errors */
|
||||
}
|
||||
shared.value = true
|
||||
window.setTimeout(() => (shared.value = false), 2000)
|
||||
}
|
||||
|
||||
// 评论
|
||||
const list = ref<Comment[]>(comments)
|
||||
const draft = ref('')
|
||||
function submitComment() {
|
||||
const content = draft.value.trim()
|
||||
if (!content) return
|
||||
list.value.unshift({
|
||||
id: `c-${list.value.length + 1}`,
|
||||
author: {
|
||||
handle: 'you',
|
||||
name: 'You',
|
||||
avatar: '',
|
||||
},
|
||||
content,
|
||||
date: new Date().toISOString(),
|
||||
likes: 0,
|
||||
})
|
||||
draft.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 互动按钮 -->
|
||||
<div
|
||||
class="xlog-reactions flex fill-gray-400 text-gray-500 sm:items-center space-x-6 sm:space-x-10 mt-14 mb-12"
|
||||
>
|
||||
<button class="button is-like" :class="{ 'text-[#f91880]': liked }" @click="toggleLike">
|
||||
<i class="i-mingcute-thumb-up-2-fill mr-2" />
|
||||
<span>{{ likeCount }}</span>
|
||||
</button>
|
||||
<button class="button is-tip" @click="tipped = !tipped">
|
||||
<i class="i-mingcute-pig-money-line mr-2" />
|
||||
<span>{{ tipped ? 'Tipped ✓' : 'Tip' }}</span>
|
||||
</button>
|
||||
<button class="button is-share" @click="share">
|
||||
<i class="i-mingcute-share-forward-line mr-2" />
|
||||
<span>{{ shared ? 'Copied!' : 'Share' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 评论区 -->
|
||||
<div class="xlog-comment mb-10" id="comments">
|
||||
<div class="xlog-comment-count border-b pb-2 mb-6 font-bold">
|
||||
Comments ({{ list.length }})
|
||||
</div>
|
||||
|
||||
<!-- 评论输入 -->
|
||||
<div class="xlog-comment-input flex mb-6">
|
||||
<span class="mr-3">
|
||||
<Avatar name="You" :size="45" />
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
v-model="draft"
|
||||
class="input is-block min-h-[74px] py-3 resize-y"
|
||||
placeholder="Write a comment..."
|
||||
@keydown.meta.enter="submitComment"
|
||||
@keydown.ctrl.enter="submitComment"
|
||||
/>
|
||||
<div class="flex justify-end mt-2">
|
||||
<button
|
||||
class="button is-primary is-sm"
|
||||
:class="{ 'is-loading': false }"
|
||||
:disabled="!draft.trim()"
|
||||
@click="submitComment"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评论列表 -->
|
||||
<div class="xlog-comment-list">
|
||||
<div
|
||||
v-for="comment in list"
|
||||
:key="comment.id"
|
||||
class="xlog-comment-item mt-6 flex space-x-3"
|
||||
>
|
||||
<Avatar :src="comment.author.avatar" :name="comment.author.name" :size="40" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<span class="font-bold">{{ comment.author.name }}</span>
|
||||
<span class="text-zinc-400 text-xs">
|
||||
<Time :date="comment.date" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-zinc-600 mt-1 leading-relaxed break-words">
|
||||
{{ comment.content }}
|
||||
</div>
|
||||
<button
|
||||
class="text-zinc-400 text-xs mt-1 hover:text-accent inline-flex items-center"
|
||||
>
|
||||
<i class="i-mingcute-thumb-up-2-line mr-1" />
|
||||
{{ comment.likes }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Post } from '@/mock/data'
|
||||
|
||||
import Time from '@/components/common/Time.vue'
|
||||
|
||||
defineProps<{ post: Post }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="xlog-post-meta">
|
||||
<div class="text-zinc-400 mt-5 space-x-5 flex items-center justify-center">
|
||||
<!-- 发布日期 -->
|
||||
<Time class="xlog-post-date whitespace-nowrap" :date="post.date" />
|
||||
<!-- 标签 -->
|
||||
<span class="xlog-post-tags space-x-1 truncate min-w-0">
|
||||
<a
|
||||
v-for="tag in post.tags"
|
||||
:key="tag"
|
||||
class="hover:text-accent"
|
||||
:href="`/tag/${tag}`"
|
||||
>
|
||||
#{{ tag }}
|
||||
</a>
|
||||
</span>
|
||||
<!-- 阅读量 -->
|
||||
<span class="xlog-post-views inline-flex items-center">
|
||||
<i class="i-mingcute-eye-line mr-[2px]" />
|
||||
<span>{{ post.views }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Post } from '@/mock/data'
|
||||
|
||||
defineProps<{ post: Post }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2
|
||||
class="xlog-post-title mb-8 flex items-center justify-center text-center relative text-4xl font-extrabold"
|
||||
>
|
||||
<span>{{ post.title }}</span>
|
||||
</h2>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 站点页 Footer:版权 + 语言/暗色切换
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import DarkModeSwitch from '@/components/common/DarkModeSwitch.vue'
|
||||
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
import { sites } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
const site = computed(
|
||||
() => sites.find((s) => s.handle === (route.params.site as string)) ?? sites[0],
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="text-zinc-500 border-t">
|
||||
<div class="max-w-screen-lg mx-auto px-5 py-10">
|
||||
<div
|
||||
class="text-xs sm:flex justify-between sm:space-x-5 sm:space-y-0 space-y-5 sm:items-center"
|
||||
>
|
||||
<div class="font-medium text-base">
|
||||
©
|
||||
<RouterLink :to="`/site/${site.handle}`" class="hover:text-accent">
|
||||
{{ site.name }}
|
||||
</RouterLink>
|
||||
· powered by
|
||||
<RouterLink to="/" class="inline-flex items-center align-middle hover:text-accent">
|
||||
<span class="inline-block size-5 mr-1">
|
||||
<Logo :size="20" />
|
||||
</span>
|
||||
xLog
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex gap-x-2 items-center justify-center">
|
||||
<LanguageSwitch />
|
||||
<DarkModeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -1,73 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 站点信息头:Banner + 头像 + 站点名/简介 + 关注按钮 + 导航
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { sites } from '@/mock/data'
|
||||
|
||||
import SiteTabs from './SiteTabs.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const site = computed(
|
||||
() => sites.find((s) => s.handle === (route.params.site as string)) ?? sites[0],
|
||||
)
|
||||
|
||||
const following = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="xlog-header border-b border-zinc-100 relative">
|
||||
<!-- 可选 Banner 图 -->
|
||||
<div v-if="site.banner" class="xlog-banner absolute inset-0 overflow-hidden">
|
||||
<img class="object-cover w-full h-full" :src="site.banner" alt="banner" />
|
||||
</div>
|
||||
|
||||
<div class="px-5 max-w-screen-lg mx-auto h-full relative flex items-center flex-col z-10">
|
||||
<div class="flex py-12 w-full">
|
||||
<div class="xlog-site-info flex space-x-6 sm:space-x-8 w-full">
|
||||
<!-- 头像 -->
|
||||
<img
|
||||
class="xlog-site-icon max-w-[100px] max-h-[100px] sm:max-w-none sm:max-h-none rounded-full bg-zinc-100"
|
||||
:src="site.avatar"
|
||||
width="150"
|
||||
height="150"
|
||||
:alt="`${site.name} avatar`"
|
||||
/>
|
||||
|
||||
<!-- 站点信息 -->
|
||||
<div class="flex-1 min-w-0 relative space-y-2 sm:space-y-3 min-h-[108px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1
|
||||
class="xlog-site-name text-3xl sm:text-4xl font-bold text-zinc-900 leading-snug break-words min-w-0"
|
||||
>
|
||||
{{ site.name }}
|
||||
</h1>
|
||||
<div class="ml-0 sm:ml-8 space-x-3 sm:space-x-4 flex items-center">
|
||||
<button
|
||||
class="button is-primary is-sm"
|
||||
@click="following = !following"
|
||||
>
|
||||
{{ following ? 'Following' : 'Follow' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 简介 -->
|
||||
<div
|
||||
class="xlog-site-description text-gray-500 leading-snug text-sm sm:text-base line-clamp-4 whitespace-pre-wrap"
|
||||
>
|
||||
{{ site.description }}
|
||||
</div>
|
||||
<!-- 关注数 -->
|
||||
<span class="text-sm text-zinc-400">{{ site.followers }} followers</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航栏 -->
|
||||
<div class="text-gray-500 flex items-center justify-between w-full mt-auto">
|
||||
<SiteTabs />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 站点头导航:Home / Archives
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Tabs from '@/components/ui/Tabs.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const items = computed(() => {
|
||||
const site = route.params.site as string
|
||||
return [
|
||||
{ key: 'home', label: 'Home', to: `/site/${site}` },
|
||||
{ key: 'archives', label: 'Archives', to: `/site/${site}/archives` },
|
||||
]
|
||||
})
|
||||
|
||||
const activeKey = computed(() =>
|
||||
route.path.includes('/archives') ? 'archives' : 'home',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs :items="items" :active-key="activeKey" class="border-none mb-0" />
|
||||
</template>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 通用 Tabs:underline(下划线)与 rounded(胶囊)两种类型
|
||||
// 有 `to` 的项渲染为 RouterLink,无 `to` 的项渲染为按钮并触发 select
|
||||
export interface TabItem {
|
||||
key: string
|
||||
label: string
|
||||
to?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ items: TabItem[]; activeKey: string; type?: 'underline' | 'rounded' }>(),
|
||||
{ type: 'underline' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ select: [key: string] }>()
|
||||
|
||||
function cls(item: TabItem) {
|
||||
const base =
|
||||
'inline-flex items-center whitespace-nowrap cursor-pointer transition-colors relative'
|
||||
if (props.type === 'rounded') {
|
||||
return [
|
||||
base,
|
||||
'rounded-full h-8 px-3',
|
||||
props.activeKey === item.key
|
||||
? 'bg-zinc-950 text-white'
|
||||
: 'bg-zinc-100 text-zinc-800 hover:bg-zinc-200',
|
||||
].join(' ')
|
||||
}
|
||||
return [
|
||||
base,
|
||||
'h-10',
|
||||
props.activeKey === item.key
|
||||
? 'text-accent font-medium border-b-2 border-accent'
|
||||
: 'text-gray-600 hover:text-accent',
|
||||
].join(' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex mb-8 overflow-x-auto scrollbar-hide"
|
||||
:class="type === 'rounded' ? 'space-x-3 text-sm' : 'space-x-5 border-b'"
|
||||
>
|
||||
<template v-for="item in items" :key="item.key">
|
||||
<RouterLink v-if="item.to" :to="item.to" :class="cls(item)">
|
||||
<i v-if="item.icon" :class="item.icon" class="mr-2" />
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
<button v-else type="button" :class="cls(item)" @click="emit('select', item.key)">
|
||||
<i v-if="item.icon" :class="item.icon" class="mr-2" />
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const dark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
/** 暗色模式:在 <html> 上切换 .dark 类并持久化到 localStorage */
|
||||
export function useTheme() {
|
||||
function setDark(value: boolean) {
|
||||
dark.value = value
|
||||
document.documentElement.classList.toggle('dark', value)
|
||||
localStorage.setItem('theme', value ? 'dark' : 'light')
|
||||
}
|
||||
function toggle() {
|
||||
setDark(!dark.value)
|
||||
}
|
||||
return { dark, setDark, toggle }
|
||||
}
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
|
||||
export default component
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘布局:桌面端侧边栏 + 主内容;移动端顶栏 + 抽屉侧边栏
|
||||
import { ref } from 'vue'
|
||||
|
||||
import DashboardSidebar from '@/components/dashboard/DashboardSidebar.vue'
|
||||
import DashboardTopbar from '@/components/dashboard/DashboardTopbar.vue'
|
||||
|
||||
const mobileNavOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen bg-slate-100">
|
||||
<!-- 桌面端侧边栏占位 -->
|
||||
<div class="w-sidebar shrink-0 hidden lg:block">
|
||||
<DashboardSidebar />
|
||||
</div>
|
||||
|
||||
<!-- 移动端抽屉 -->
|
||||
<Transition name="drawer">
|
||||
<div
|
||||
v-if="mobileNavOpen"
|
||||
class="fixed inset-y-0 left-0 z-40 lg:hidden shadow-modal"
|
||||
>
|
||||
<DashboardSidebar />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="mobileNavOpen"
|
||||
class="fixed inset-0 z-30 bg-black/30 lg:hidden"
|
||||
@click="mobileNavOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- 移动端顶栏 -->
|
||||
<DashboardTopbar @toggle="mobileNavOpen = !mobileNavOpen" />
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="lg:p-3 size-full max-w-[calc(100vw-240px)] pt-16 lg:pt-0">
|
||||
<div class="bg-white size-full lg:rounded-xl lg:drop-shadow overflow-y-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,69 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 主页布局:固定 Header + 内容区 + Footer
|
||||
import DarkModeSwitch from '@/components/common/DarkModeSwitch.vue'
|
||||
import LanguageSwitch from '@/components/common/LanguageSwitch.vue'
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
import HomeTabs from '@/components/home/HomeTabs.vue'
|
||||
import PromotionLinks from '@/components/home/PromotionLinks.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white min-h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="py-5 fixed w-full top-0 bg-white z-[2]">
|
||||
<div class="max-w-screen-xl px-5 mx-auto flex justify-between items-center">
|
||||
<!-- 左侧:Logo + 导航 -->
|
||||
<div class="space-x-14 flex items-center">
|
||||
<RouterLink to="/" class="text-2xl font-extrabold flex items-center">
|
||||
<div class="inline-block size-9 mr-3">
|
||||
<Logo :size="36" />
|
||||
</div>
|
||||
xLog
|
||||
</RouterLink>
|
||||
<div class="hidden sm:block">
|
||||
<HomeTabs />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧:连接钱包按钮 -->
|
||||
<div class="space-x-14 text-zinc-500 flex items-center">
|
||||
<button class="button is-black">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<section class="pt-24 flex-1">
|
||||
<div class="max-w-screen-xl px-5 mx-auto flex">
|
||||
<router-view />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-10 font-medium border-t">
|
||||
<div class="max-w-screen-xl px-5 py-14 mx-auto flex flex-col sm:flex-row justify-between">
|
||||
<div class="w-full sm:w-72 space-y-4">
|
||||
<PromotionLinks />
|
||||
<div class="space-y-4">
|
||||
<RouterLink to="/about" class="flex items-center text-zinc-500 hover:text-accent">
|
||||
<i class="i-mingcute-information-line mr-2" />
|
||||
About
|
||||
</RouterLink>
|
||||
<a href="#" class="flex items-center text-zinc-500 hover:text-accent">
|
||||
<i class="i-mingcute-question-line mr-2" />
|
||||
Help
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center space-y-4 sm:space-y-0 sm:space-x-4 mx-auto sm:mx-0 mt-10 sm:mt-0 flex-col sm:flex-row"
|
||||
>
|
||||
<DarkModeSwitch />
|
||||
<LanguageSwitch />
|
||||
<span>
|
||||
© <RouterLink to="/" class="hover:text-accent">xLog</RouterLink>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 站点布局:站点信息头 + 内容区 + 站点页脚
|
||||
import SiteFooter from '@/components/site/SiteFooter.vue'
|
||||
import SiteHeader from '@/components/site/SiteHeader.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="xlog-page bg-white min-h-screen flex flex-col">
|
||||
<SiteHeader />
|
||||
<main class="xlog-post-area max-w-screen-lg mx-auto px-5 pt-8 relative w-full flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,28 +0,0 @@
|
||||
/** 数字格式化:1234 → 1.2k,1234567 → 1.2M */
|
||||
export function formatNumber(n: number): string {
|
||||
if (Number.isNaN(n)) return '0'
|
||||
if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
/** 相对时间:3 hours ago / 3 days ago */
|
||||
export function formatTime(input: string | Date): string {
|
||||
const date = new Date(input)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const diff = Date.now() - date.getTime()
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
if (diff < minute) return 'just now'
|
||||
if (diff < hour) return `${Math.floor(diff / minute)} minutes ago`
|
||||
if (diff < day) return `${Math.floor(diff / hour)} hours ago`
|
||||
if (diff < 7 * day) return `${Math.floor(diff / day)} days ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
/** 估算阅读时长(按中文 ~300 字/分钟,英文 ~200 词/分钟粗略换算为字符数) */
|
||||
export function estimateReadingTime(content: string): number {
|
||||
const minutes = content.length / 400
|
||||
return Math.max(1, Math.round(minutes))
|
||||
}
|
||||
+4
-11
@@ -1,16 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './styles/main.css'
|
||||
|
||||
// 初始化暗色模式(优先本地存储,其次系统偏好)
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const dark =
|
||||
savedTheme === 'dark' ||
|
||||
(!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
if (dark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
import { loadSite } from './site'
|
||||
import './styles.css'
|
||||
|
||||
loadSite().then(() => {
|
||||
createApp(App).use(router).mount('#app')
|
||||
})
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
// Mock 数据:后端暂未实现,先以本地数据驱动页面
|
||||
|
||||
export interface Author {
|
||||
handle: string
|
||||
name: string
|
||||
avatar: string
|
||||
banner?: string
|
||||
description?: string
|
||||
followers?: number
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string
|
||||
siteId: string
|
||||
slug: string
|
||||
title: string
|
||||
excerpt: string
|
||||
content: string
|
||||
cover?: string
|
||||
tags: string[]
|
||||
views: number
|
||||
comments: number
|
||||
likes: number
|
||||
date: string
|
||||
author: Author
|
||||
featured?: boolean
|
||||
pinned?: boolean
|
||||
status?: 'published' | 'draft' | 'scheduled'
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
author: Author
|
||||
content: string
|
||||
date: string
|
||||
likes: number
|
||||
}
|
||||
|
||||
export const avatar = (seed: string, size = 100) =>
|
||||
`https://i.pravatar.cc/${size}?img=${seed}`
|
||||
export const cover = (seed: string, w = 640, h = 360) =>
|
||||
`https://picsum.photos/seed/${seed}/${w}/${h}`
|
||||
|
||||
const authors: Record<string, Author> = {
|
||||
dirak: {
|
||||
handle: 'dirak',
|
||||
name: 'Di',
|
||||
avatar: avatar('1'),
|
||||
banner: cover('banner-dirak', 1024, 300),
|
||||
description: 'Building a more connected, open, and elegant world.',
|
||||
followers: 1284,
|
||||
},
|
||||
she: {
|
||||
handle: 'she',
|
||||
name: 'She',
|
||||
avatar: avatar('2'),
|
||||
banner: cover('banner-she', 1024, 300),
|
||||
description: 'Frontend engineer. Loves Vue, design systems & coffee.',
|
||||
followers: 932,
|
||||
},
|
||||
kyoko: {
|
||||
handle: 'kyoko',
|
||||
name: 'Kyoko',
|
||||
avatar: avatar('3'),
|
||||
banner: cover('banner-kyoko', 1024, 300),
|
||||
description: 'Indie hacker writing about web3, notes & life.',
|
||||
followers: 2107,
|
||||
},
|
||||
vincent: {
|
||||
handle: 'vincent',
|
||||
name: 'Vincent',
|
||||
avatar: avatar('4'),
|
||||
banner: cover('banner-vincent', 1024, 300),
|
||||
description: 'Photographer & blogger. Film everywhere.',
|
||||
followers: 566,
|
||||
},
|
||||
aki: {
|
||||
handle: 'aki',
|
||||
name: 'Aki',
|
||||
avatar: avatar('5'),
|
||||
banner: cover('banner-aki', 1024, 300),
|
||||
description: 'Full-stack dev. Open source enthusiast.',
|
||||
followers: 1490,
|
||||
},
|
||||
}
|
||||
|
||||
export const sites: Author[] = Object.values(authors)
|
||||
|
||||
const posts: Post[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
siteId: 'dirak',
|
||||
slug: 'welcome-to-xlog',
|
||||
title: 'Welcome to xLog — a new home for your blog',
|
||||
excerpt:
|
||||
'xLog is a decentralized blog platform built on Crossbell. It gives every creator a permanent home on the open web.',
|
||||
content: `# Welcome to xLog
|
||||
|
||||
This is an **example post** rendered from Markdown. xLog is a blog platform built on Crossbell that gives every creator a permanent home on the open web.
|
||||
|
||||
> Share your words with the world, and own them forever.
|
||||
|
||||
## Why we built it
|
||||
|
||||
- **Own your content** — everything lives on the blockchain.
|
||||
- **Open & connected** — follow, comment, tip across sites.
|
||||
- **Beautiful by default** — a clean reading experience.
|
||||
|
||||
## Getting started
|
||||
|
||||
\`\`\`bash
|
||||
npx create-xlog
|
||||
\`\`\`
|
||||
|
||||
Check out the [docs](https://xlog.app) for more.`,
|
||||
cover: cover('welcome', 640, 360),
|
||||
tags: ['intro', 'web3'],
|
||||
views: 1234,
|
||||
comments: 18,
|
||||
likes: 42,
|
||||
date: '2026-07-28',
|
||||
author: authors.dirak,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
siteId: 'dirak',
|
||||
slug: 'designing-with-css-variables',
|
||||
title: 'Designing with CSS variables and the modern dark mode',
|
||||
excerpt:
|
||||
'CSS variables make theming feel effortless. Here is how we keep the entire UI consistent with just a few custom properties.',
|
||||
content: `# Designing with CSS variables
|
||||
|
||||
CSS custom properties are a superpower for theming. With a handful of variables you can drive the entire visual language of an app.
|
||||
|
||||
\`\`\`css
|
||||
html {
|
||||
--theme-color: #f97316;
|
||||
--border-color: #eee;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Dark mode made easy
|
||||
|
||||
Toggle a class on \`<html>\`, and the variables re-resolve:
|
||||
|
||||
\`\`\`css
|
||||
html.dark {
|
||||
--theme-color: #ea580c;
|
||||
--border-color: #333;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
That is the whole trick.`,
|
||||
cover: cover('cssvar', 640, 360),
|
||||
tags: ['css', 'design'],
|
||||
views: 892,
|
||||
comments: 7,
|
||||
likes: 31,
|
||||
date: '2026-07-21',
|
||||
author: authors.dirak,
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
siteId: 'dirak',
|
||||
slug: 'building-a-blog-on-crossbell',
|
||||
title: 'Building a blog on Crossbell: notes from the field',
|
||||
excerpt:
|
||||
'A practical walkthrough of publishing posts, claiming CSB tokens, and connecting with readers on the open network.',
|
||||
content: `# Building on Crossbell
|
||||
|
||||
Crossbell gives you the primitives to build a fully on-chain blog. Let's walk through the key steps.
|
||||
|
||||
1. Create a character
|
||||
2. Publish posts
|
||||
3. Claim CSB
|
||||
4. Connect with readers`,
|
||||
cover: cover('crossbell', 640, 360),
|
||||
tags: ['tutorial', 'crossbell'],
|
||||
views: 2011,
|
||||
comments: 24,
|
||||
likes: 87,
|
||||
date: '2026-07-14',
|
||||
author: authors.dirak,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
siteId: 'she',
|
||||
slug: 'vue-3-composition-api-guide',
|
||||
title: 'A practical guide to Vue 3 Composition API',
|
||||
excerpt:
|
||||
'Stop copying snippets and start understanding setup functions, refs, and computed. A guide for busy developers.',
|
||||
content: `# Vue 3 Composition API
|
||||
|
||||
The Composition API gives you better code organization and reusability.
|
||||
|
||||
\`\`\`ts
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const count = ref(0)
|
||||
const double = computed(() => count.value * 2)
|
||||
\`\`\`
|
||||
|
||||
That's the essence.`,
|
||||
cover: cover('vue3', 640, 360),
|
||||
tags: ['vue', 'frontend'],
|
||||
views: 1750,
|
||||
comments: 12,
|
||||
likes: 64,
|
||||
date: '2026-07-25',
|
||||
author: authors.she,
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
siteId: 'she',
|
||||
slug: 'tailwind-vs-css-modules',
|
||||
title: 'Tailwind vs CSS Modules: what we actually use',
|
||||
excerpt:
|
||||
'A pragmatic comparison from someone who has shipped both. Spoiler: the answer is "it depends" — but here is our default.',
|
||||
content: `# Tailwind vs CSS Modules
|
||||
|
||||
Both are great. We default to Tailwind for speed and consistency, with CSS modules reserved for truly isolated components.`,
|
||||
cover: cover('tailwind', 640, 360),
|
||||
tags: ['css', 'tailwind'],
|
||||
views: 643,
|
||||
comments: 5,
|
||||
likes: 22,
|
||||
date: '2026-07-18',
|
||||
status: 'draft',
|
||||
author: authors.she,
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
siteId: 'kyoko',
|
||||
slug: 'on-chain-blogging',
|
||||
title: 'Why I moved my blog on-chain',
|
||||
excerpt:
|
||||
'Censorship resistance, portability, and an open social graph — the reasons that finally convinced me to leave the walled gardens.',
|
||||
content: `# Why on-chain?
|
||||
|
||||
When your words live on a blockchain, no platform can take them away.
|
||||
|
||||
- Censorship resistant
|
||||
- Portable across frontends
|
||||
- Own your social graph
|
||||
|
||||
It's not for everyone, but it is for writers who care about permanence.`,
|
||||
cover: cover('onchain', 640, 360),
|
||||
tags: ['web3', 'opinion'],
|
||||
views: 3310,
|
||||
comments: 45,
|
||||
likes: 132,
|
||||
date: '2026-07-22',
|
||||
author: authors.kyoko,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'p7',
|
||||
siteId: 'kyoko',
|
||||
slug: 'indie-hacking-notes',
|
||||
title: 'Indie hacking: three months of shipping in public',
|
||||
excerpt:
|
||||
'Revenue numbers, mental models, and the mistakes I will not repeat. An honest recap of a quarter spent building in public.',
|
||||
content: `# Three months of shipping in public
|
||||
|
||||
Here's what worked, what didn't, and what I'd do differently.
|
||||
|
||||
## Wins
|
||||
- Launched the MVP in 6 weeks
|
||||
- 400 signups from a single post
|
||||
|
||||
## Losses
|
||||
- Churned a third of early users
|
||||
- Spent too long polishing internals`,
|
||||
cover: cover('indie', 640, 360),
|
||||
tags: ['indie', 'business'],
|
||||
views: 2214,
|
||||
comments: 28,
|
||||
likes: 95,
|
||||
date: '2026-07-10',
|
||||
author: authors.kyoko,
|
||||
},
|
||||
{
|
||||
id: 'p8',
|
||||
siteId: 'vincent',
|
||||
slug: 'film-vs-digital',
|
||||
title: 'Film vs digital: a photographer confession',
|
||||
excerpt:
|
||||
'After a decade of shooting digital, I picked up a film camera. Here is what it taught me about slowing down.',
|
||||
content: `# Film vs digital
|
||||
|
||||
Shooting film changed how I see. Twenty exposures per roll means every frame matters.
|
||||
|
||||
> Less is more — sometimes.`,
|
||||
cover: cover('film', 640, 360),
|
||||
tags: ['photography'],
|
||||
views: 987,
|
||||
comments: 9,
|
||||
likes: 41,
|
||||
date: '2026-07-19',
|
||||
status: 'draft',
|
||||
author: authors.vincent,
|
||||
},
|
||||
{
|
||||
id: 'p9',
|
||||
siteId: 'vincent',
|
||||
slug: 'city-of-neon',
|
||||
title: 'City of neon: a night photography walkthrough',
|
||||
excerpt:
|
||||
'Tips for shooting neon signs at night: exposure, white balance, and finding the light in the rain.',
|
||||
content: `# City of neon
|
||||
|
||||
Night photography is a game of patience and light.
|
||||
|
||||
- Bring a tripod
|
||||
- Meter for highlights
|
||||
- Embrace the rain for reflections`,
|
||||
cover: cover('neon', 640, 360),
|
||||
tags: ['photography', 'city'],
|
||||
views: 1455,
|
||||
comments: 11,
|
||||
likes: 58,
|
||||
date: '2026-07-05',
|
||||
author: authors.vincent,
|
||||
},
|
||||
{
|
||||
id: 'p10',
|
||||
siteId: 'aki',
|
||||
slug: 'open-source-lessons',
|
||||
title: 'Lessons from maintaining an open source project',
|
||||
excerpt:
|
||||
'Maintainer burn-out, issue triage, and why saying "no" is a feature — everything I learned the hard way.',
|
||||
content: `# Maintaining open source
|
||||
|
||||
Maintenance is where most projects fail, not inception.
|
||||
|
||||
1. Triage ruthlessly
|
||||
2. Document everything
|
||||
3. Automate the boring parts
|
||||
|
||||
Saying "no" is a feature, not a failure.`,
|
||||
cover: cover('oss', 640, 360),
|
||||
tags: ['opensource'],
|
||||
views: 1766,
|
||||
comments: 15,
|
||||
likes: 71,
|
||||
date: '2026-06-30',
|
||||
author: authors.aki,
|
||||
},
|
||||
{
|
||||
id: 'p11',
|
||||
siteId: 'aki',
|
||||
slug: 'serverless-edge',
|
||||
title: 'Serverless at the edge: is it worth the hype?',
|
||||
excerpt:
|
||||
'Cold starts, cost surprises, and when edge functions genuinely beat a plain old server.',
|
||||
content: `# Serverless at the edge
|
||||
|
||||
The edge is great — until it isn't.
|
||||
|
||||
Use it for: personalization, geo-routing, caching.
|
||||
Skip it for: long-running tasks, heavy compute.`,
|
||||
cover: cover('edge', 640, 360),
|
||||
tags: ['backend', 'serverless'],
|
||||
views: 1088,
|
||||
comments: 13,
|
||||
likes: 49,
|
||||
date: '2026-06-25',
|
||||
status: 'scheduled',
|
||||
author: authors.aki,
|
||||
},
|
||||
]
|
||||
|
||||
export const allPosts: Post[] = posts
|
||||
export const getPost = (siteId: string, slug: string) =>
|
||||
posts.find((p) => p.siteId === siteId && p.slug === slug)
|
||||
|
||||
export const comments: Comment[] = [
|
||||
{
|
||||
id: 'c1',
|
||||
author: authors.she,
|
||||
content:
|
||||
'Great writeup! The dark mode section was exactly what I needed. Thanks for sharing.',
|
||||
date: '2026-07-29',
|
||||
likes: 3,
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
author: authors.kyoko,
|
||||
content: 'Bookmarked this. CSS variables are so underrated.',
|
||||
date: '2026-07-28',
|
||||
likes: 5,
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
author: authors.vincent,
|
||||
content: 'Could you share the full config file? Would love to reference it.',
|
||||
date: '2026-07-27',
|
||||
likes: 1,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'home', component: () => import('./views/HomeView.vue') },
|
||||
{ path: '/post/:slug', name: 'post', component: () => import('./views/PostView.vue') },
|
||||
{ path: '/archive', name: 'archive', component: () => import('./views/ArchiveView.vue') },
|
||||
{ path: '/tags', name: 'tags', component: () => import('./views/TagsView.vue') },
|
||||
{ path: '/tag/:slug', name: 'tag', component: () => import('./views/TagView.vue') },
|
||||
{ path: '/about', name: 'about', component: () => import('./views/AboutView.vue') },
|
||||
|
||||
{ path: '/admin/login', name: 'admin-login', component: () => import('./admin/LoginView.vue') },
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('./admin/AdminLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'admin-posts', component: () => import('./admin/PostsView.vue') },
|
||||
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') },
|
||||
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
|
||||
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
|
||||
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') }
|
||||
]
|
||||
},
|
||||
|
||||
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('./views/NotFoundView.vue') }
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, saved) {
|
||||
return saved || { top: 0 }
|
||||
}
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import HomeLayout from '@/layouts/HomeLayout.vue'
|
||||
import SiteLayout from '@/layouts/SiteLayout.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
// 主页布局:Header + Footer
|
||||
path: '/',
|
||||
component: HomeLayout,
|
||||
children: [
|
||||
{ path: '', name: 'home', component: () => import('@/views/HomePage.vue') },
|
||||
{ path: 'shorts', name: 'shorts', component: () => import('@/views/HomePage.vue') },
|
||||
{ path: 'latest', name: 'latest', component: () => import('@/views/HomePage.vue') },
|
||||
{ path: 'hottest', name: 'hottest', component: () => import('@/views/HomePage.vue') },
|
||||
{ path: 'following', name: 'following', component: () => import('@/views/HomePage.vue') },
|
||||
{ path: 'about', name: 'about', component: () => import('@/views/AboutPage.vue') },
|
||||
{ path: 'search', name: 'search', component: () => import('@/views/SearchPage.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
// 站点布局:站点头 + 文章内容
|
||||
path: '/',
|
||||
component: SiteLayout,
|
||||
children: [
|
||||
{
|
||||
path: 'post/:site/:slug',
|
||||
name: 'post',
|
||||
component: () => import('@/views/PostDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: 'site/:site',
|
||||
name: 'site',
|
||||
component: () => import('@/views/SiteHome.vue'),
|
||||
},
|
||||
{
|
||||
path: 'site/:site/archives',
|
||||
name: 'site-archives',
|
||||
component: () => import('@/views/SiteArchives.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// 仪表盘布局:侧边栏 + 主内容
|
||||
path: '/dashboard',
|
||||
component: DashboardLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: 'posts',
|
||||
name: 'dashboard-posts',
|
||||
component: () => import('@/views/PostsManager.vue'),
|
||||
},
|
||||
{
|
||||
path: 'pages',
|
||||
name: 'dashboard-pages',
|
||||
component: () => import('@/views/PagesManager.vue'),
|
||||
},
|
||||
{
|
||||
path: 'editor',
|
||||
name: 'dashboard-editor',
|
||||
component: () => import('@/views/EditorPlaceholder.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/:tab?',
|
||||
name: 'dashboard-settings',
|
||||
component: () => import('@/views/SettingsGeneral.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
},
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
export { router }
|
||||
@@ -0,0 +1,29 @@
|
||||
import { reactive } from 'vue'
|
||||
import { publicApi } from './api'
|
||||
|
||||
export const site = reactive({
|
||||
site_title: 'ONE · 一个博客',
|
||||
site_desc: '长文与短文,同一种节奏。',
|
||||
author_name: 'ONE',
|
||||
author_bio: '写点长的,也写点短的。',
|
||||
footer_note: '© ONE · 一个博客',
|
||||
icp: '',
|
||||
posts_per_page: 10,
|
||||
loaded: false
|
||||
})
|
||||
|
||||
export async function loadSite() {
|
||||
try {
|
||||
Object.assign(site, await publicApi.site())
|
||||
site.loaded = true
|
||||
} catch (e) {
|
||||
// 站点还没起来时用默认值,不阻塞首屏
|
||||
site.loaded = false
|
||||
}
|
||||
return site
|
||||
}
|
||||
|
||||
export function applyDocTitle(sub) {
|
||||
const base = site.site_title || 'ONE'
|
||||
document.title = sub ? `${sub} · ${base}` : base
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
/* ONE — 设计 token(沿用 ROOT-2 第 7 套「融合 + Twitter 信息流」) */
|
||||
|
||||
:root {
|
||||
/* 皮肤:米白纸感 */
|
||||
--paper: #faf7f1;
|
||||
--card: #fffdf8;
|
||||
--paper-sunken: #f3efe6;
|
||||
|
||||
/* 墨色:正文暖灰,不用纯黑 */
|
||||
--ink: #33302b;
|
||||
--ink-soft: #5c564d;
|
||||
--muted: #8a8378;
|
||||
--faint: #a9a297;
|
||||
|
||||
/* 强调色:降饱和蓝(只出现在少数几处) */
|
||||
--accent: #3d7f9c;
|
||||
--accent-soft: rgba(61, 127, 156, 0.1);
|
||||
--accent-line: rgba(61, 127, 156, 0.35);
|
||||
|
||||
/* 细线:纸感的分割靠 1px 线,不靠阴影 */
|
||||
--line: #e6e0d4;
|
||||
--line-soft: #efe9dd;
|
||||
|
||||
/* 字体:标题衬线,正文黑体 */
|
||||
--serif: Georgia, "Times New Roman", "Songti SC", "Noto Serif SC", "Source Han Serif SC", serif;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--lh: 1.95;
|
||||
|
||||
/* 三栏骨架 */
|
||||
--col-left: 236px;
|
||||
--col-main: 640px;
|
||||
--col-right: 306px;
|
||||
--gutter: 28px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 16px;
|
||||
line-height: var(--lh);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
/* 移动端不留横向滚动 */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(61, 127, 156, 0.18);
|
||||
}
|
||||
|
||||
/* ---------- 通用容器 ---------- */
|
||||
|
||||
.shell {
|
||||
max-width: 1238px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: var(--col-left) minmax(0, var(--col-main)) var(--col-right);
|
||||
gap: var(--gutter);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.layout {
|
||||
grid-template-columns: var(--col-left) minmax(0, 1fr);
|
||||
}
|
||||
.layout > .rail-right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.layout > .rail-left {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 小组件 ---------- */
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
border: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
color: var(--ink-soft);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tag-chip:hover {
|
||||
border-color: var(--accent-line);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 7px 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
border: 1px solid var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: #356f88;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pill-ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.pill-ghost:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- 表单 ---------- */
|
||||
|
||||
.field {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.field > label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.textarea:focus,
|
||||
.select:focus {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--accent-line);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #356f88;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
border-color: #b4553f;
|
||||
color: #b4553f;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---------- 正文排版(长文:首字下沉 + 1.95 行高) ---------- */
|
||||
|
||||
.prose {
|
||||
font-size: 16.5px;
|
||||
line-height: var(--lh);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.prose > :first-child {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin: 0 0 1.1em;
|
||||
}
|
||||
|
||||
/* 首段首字下沉 */
|
||||
.prose > p:first-of-type::first-letter {
|
||||
float: left;
|
||||
font-family: var(--serif);
|
||||
font-size: 52px;
|
||||
line-height: 1;
|
||||
margin: 6px 10px 0 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4 {
|
||||
font-family: var(--serif);
|
||||
color: var(--ink);
|
||||
margin: 1.8em 0 0.6em;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-size: 22px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.prose h3::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 2px;
|
||||
margin-top: 8px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
font-size: 16.5px;
|
||||
}
|
||||
|
||||
/* 引言用衬线,和正文区分开 */
|
||||
.prose blockquote {
|
||||
margin: 1.4em 0;
|
||||
padding: 2px 0 2px 18px;
|
||||
border-left: 2px solid var(--accent-line);
|
||||
font-family: var(--serif);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.prose blockquote p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
margin: 0 0 1.1em;
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid var(--accent-line);
|
||||
}
|
||||
|
||||
.prose a:hover {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.prose code {
|
||||
font-family: var(--mono);
|
||||
font-size: 13.5px;
|
||||
background: var(--paper-sunken);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: var(--paper-sunken);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
background: var(--line);
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.4em 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.prose th,
|
||||
.prose td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background: var(--paper-sunken);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 短文:放大正文,不做首字下沉 */
|
||||
.prose-short {
|
||||
font-size: 20px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.prose-short > p:first-of-type::first-letter {
|
||||
float: none;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---------- 空状态 / 加载 ---------- */
|
||||
|
||||
.empty {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
@import './variables.css';
|
||||
@import './tailwind.css';
|
||||
@import './prose.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* body 基础 */
|
||||
body {
|
||||
@apply text-zinc-900;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/* 文章排版 */
|
||||
|
||||
@layer components {
|
||||
.prose {
|
||||
@apply text-zinc-600 leading-loose;
|
||||
word-break: break-word;
|
||||
}
|
||||
.prose > p {
|
||||
@apply mb-5;
|
||||
}
|
||||
.prose h2 {
|
||||
@apply text-3xl font-semibold my-7 text-zinc-900;
|
||||
}
|
||||
.prose h3 {
|
||||
@apply text-2xl font-semibold my-7 text-zinc-900;
|
||||
}
|
||||
.prose h4 {
|
||||
@apply text-xl font-semibold my-7 text-zinc-900;
|
||||
}
|
||||
.prose h1 {
|
||||
@apply text-4xl font-bold my-8 text-zinc-900;
|
||||
}
|
||||
.prose blockquote {
|
||||
@apply border-l-4 border-zinc-300 pl-5 my-3 text-zinc-500;
|
||||
}
|
||||
.prose a {
|
||||
@apply text-accent hover:underline;
|
||||
}
|
||||
.prose ul {
|
||||
@apply list-disc pl-5 mb-5;
|
||||
}
|
||||
.prose ol {
|
||||
@apply list-decimal pl-6 mb-5;
|
||||
}
|
||||
.prose li {
|
||||
@apply mb-1;
|
||||
}
|
||||
.prose img {
|
||||
@apply inline-block;
|
||||
}
|
||||
.prose code {
|
||||
@apply bg-zinc-100 rounded px-1 py-0.5 text-sm font-mono;
|
||||
}
|
||||
.prose pre {
|
||||
@apply bg-zinc-900 text-zinc-100 rounded-xl p-4 my-5 overflow-x-auto text-sm;
|
||||
}
|
||||
.prose pre code {
|
||||
@apply bg-transparent p-0 text-inherit;
|
||||
}
|
||||
.prose hr {
|
||||
@apply border-zinc-200 my-8;
|
||||
}
|
||||
.prose table {
|
||||
@apply w-full text-left text-sm border-collapse my-5;
|
||||
}
|
||||
.prose th {
|
||||
@apply border-b border-zinc-200 py-2 pr-4 font-semibold text-zinc-900;
|
||||
}
|
||||
.prose td {
|
||||
@apply border-b border-zinc-200 py-2 pr-4;
|
||||
}
|
||||
|
||||
/* 代码块包裹 */
|
||||
.prose .code-wrapper {
|
||||
@apply relative text-base;
|
||||
}
|
||||
.prose .code-wrapper .copy-button {
|
||||
@apply absolute top-3 right-3 hidden text-sm space-x-1;
|
||||
@apply text-zinc-100 bg-zinc-600/50 rounded-lg h-7 px-3 items-center;
|
||||
}
|
||||
.prose .code-wrapper:hover .copy-button {
|
||||
@apply inline-flex;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/* 通用组件类:按钮 / 输入框 / 隐藏滚动条 */
|
||||
|
||||
@layer components {
|
||||
/* 按钮基础样式 */
|
||||
.button {
|
||||
@apply inline-flex items-center justify-center h-9 px-5 min-w-[100px];
|
||||
@apply whitespace-nowrap font-medium transition active:scale-95;
|
||||
@apply focus-visible:outline focus-visible:outline-accent;
|
||||
}
|
||||
.button.is-primary {
|
||||
@apply text-white bg-accent opacity-90 hover:opacity-100;
|
||||
}
|
||||
.button.is-secondary {
|
||||
@apply bg-gray-50 hover:bg-gray-100 text-gray-500;
|
||||
}
|
||||
.button.is-text {
|
||||
@apply shadow-none py-0 px-3 min-w-0 hover:bg-hover;
|
||||
}
|
||||
.button.is-sm {
|
||||
@apply h-7 px-3 text-sm min-w-[auto];
|
||||
}
|
||||
.button.is-xl {
|
||||
@apply h-10 px-6 text-xl;
|
||||
}
|
||||
.button.is-block {
|
||||
@apply w-full;
|
||||
}
|
||||
.button.is-loading {
|
||||
@apply opacity-50;
|
||||
}
|
||||
.button.is-black {
|
||||
@apply bg-black hover:bg-white focus:bg-white;
|
||||
@apply text-white hover:text-black focus:text-black;
|
||||
@apply border-black border;
|
||||
}
|
||||
.button.is-like {
|
||||
@apply hover:bg-[#f91880]/10 hover:text-[#f91880];
|
||||
}
|
||||
.button.is-collect {
|
||||
@apply hover:bg-[#ffcf55]/10 hover:text-[#ffcf55];
|
||||
}
|
||||
.button.is-tip {
|
||||
@apply hover:bg-[#facc15]/10 hover:text-[#facc15];
|
||||
}
|
||||
.button.is-share {
|
||||
@apply hover:bg-[#0ea5e9]/10 hover:text-[#0ea5e9];
|
||||
}
|
||||
.button.is-comment {
|
||||
@apply hover:bg-green-500/10 hover:text-green-500;
|
||||
}
|
||||
.button.is-outline {
|
||||
@apply border-black text-black border;
|
||||
}
|
||||
.button.is-2xl {
|
||||
@apply h-12 px-6 text-xl;
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
.button-group {
|
||||
@apply flex items-center;
|
||||
}
|
||||
.button-group .button:not(:first-child) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
.button-group .button:not(:last-child) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
.input {
|
||||
@apply border outline-none rounded-lg px-3 h-10 inline-flex items-center;
|
||||
@apply focus:ring-1 focus:border-accent;
|
||||
--tw-ring-color: var(--theme-color);
|
||||
}
|
||||
.input.is-block {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
/* 隐藏横向滚动条(Tabs 容器) */
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
html {
|
||||
--border-color: #eee;
|
||||
--theme-color: var(--auto-theme-color, #f97316); /* orange-500 */
|
||||
--hover-color: var(--auto-hover-color, #f4f4f5); /* zinc-100 */
|
||||
--header-height: auto;
|
||||
accent-color: var(--theme-color);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scrollbar-color: var(--theme-color) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--border-color: #333;
|
||||
--theme-color: var(--auto-theme-color, #ea580c); /* orange-600 */
|
||||
}
|
||||
|
||||
/* 暗色模式下颜色映射 */
|
||||
html.dark body {
|
||||
--tw-color-zinc-400: var(--tw-color-zinc-500);
|
||||
--tw-color-zinc-500: var(--tw-color-zinc-600);
|
||||
--tw-color-zinc-600: var(--tw-color-zinc-700);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--theme-color);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true })
|
||||
|
||||
// 编辑器预览与前台渲染共用这一个入口:先 marked 渲染,再过 DOMPurify
|
||||
export function renderMarkdown(text) {
|
||||
return DOMPurify.sanitize(marked.parse(text || '', { async: false }))
|
||||
}
|
||||
|
||||
// 编辑器按 `md.render(...)` 的用法调用
|
||||
export const md = { render: renderMarkdown }
|
||||
|
||||
export function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y} 年 ${m} 月 ${day} 日`
|
||||
}
|
||||
|
||||
export function formatDateShort(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 时间线上的相对时间:三天内用「几小时前」,同年省略年份
|
||||
export function relativeDate(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const diff = Date.now() - d.getTime()
|
||||
const hour = 3600 * 1000
|
||||
if (diff < hour) return `${Math.max(1, Math.floor(diff / 60000))} 分钟前`
|
||||
if (diff < 24 * hour) return `${Math.floor(diff / hour)} 小时前`
|
||||
if (diff < 3 * 24 * hour) return `${Math.floor(diff / (24 * hour))} 天前`
|
||||
const sameYear = d.getFullYear() === new Date().getFullYear()
|
||||
return sameYear
|
||||
? `${d.getMonth() + 1} 月 ${d.getDate()} 日`
|
||||
: `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
export function stripTags(html) {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html || ''
|
||||
return div.textContent || ''
|
||||
}
|
||||
|
||||
// 短文在列表里没有标题,用正文首句当索引
|
||||
export function displayTitle(post) {
|
||||
if (post.kind === 'short') return ''
|
||||
return post.title || '无题'
|
||||
}
|
||||
|
||||
export function excerpt(post, limit = 60) {
|
||||
if (post.summary) return post.summary
|
||||
const text = stripTags(post.content_html || '')
|
||||
return text.length > limit ? text.slice(0, limit) + '…' : text
|
||||
}
|
||||
|
||||
export function minutesLabel(post) {
|
||||
if (post.kind === 'short') return '短文'
|
||||
return `${post.reading_minutes || 1} 分钟`
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 关于页
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
import PromotionLinks from '@/components/home/PromotionLinks.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-lg mx-auto py-8">
|
||||
<div class="text-center py-10 space-y-6">
|
||||
<div class="inline-block size-16 mx-auto">
|
||||
<Logo :size="64" />
|
||||
</div>
|
||||
<h1 class="text-4xl font-extrabold">About xLog</h1>
|
||||
<p class="text-zinc-500 max-w-xl mx-auto leading-loose">
|
||||
xLog is a decentralized blog platform built on Crossbell. It gives every
|
||||
creator a permanent home on the open web — own your content, connect with
|
||||
readers, and tip creators across the network.
|
||||
</p>
|
||||
<div class="max-w-sm mx-auto">
|
||||
<PromotionLinks />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
|
||||
applyDocTitle('关于')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="head">
|
||||
<h1 class="page-title">关于</h1>
|
||||
</header>
|
||||
|
||||
<div class="wrap prose">
|
||||
<p>{{ site.site_desc || '长文与短文,同一种节奏。' }}</p>
|
||||
<h2>这里写什么</h2>
|
||||
<p>
|
||||
<strong>长文</strong>是正常的博客文章:有标题、有结构,适合把一件事讲完整。
|
||||
</p>
|
||||
<p>
|
||||
<strong>短文</strong>像一条推文:一两段话,没有标题,用来记一个念头、一段心情。
|
||||
它们在同一条时间线里流动,只是密度不同。
|
||||
</p>
|
||||
<h2>订阅</h2>
|
||||
<p>
|
||||
站点提供 RSS:<a href="/rss.xml">/rss.xml</a>,短文在标题里带「短」标记。
|
||||
</p>
|
||||
<p class="foot">{{ site.footer_note }}</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 24px 26px 40px;
|
||||
}
|
||||
|
||||
.foot {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 18px 4px 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { applyDocTitle } from '../site'
|
||||
import { formatDateShort } from '../utils'
|
||||
|
||||
const years = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await publicApi.archive()
|
||||
years.value = data.years || []
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const total = computed(() =>
|
||||
years.value.reduce((n, y) => n + (y.count || 0), 0)
|
||||
)
|
||||
|
||||
applyDocTitle('归档')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="head">
|
||||
<h1 class="page-title">归档</h1>
|
||||
<p class="sub">共 {{ total }} 篇,按时间倒序。</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!years.length" class="empty">还没有已发布的文章。</div>
|
||||
|
||||
<section v-for="y in years" :key="y.year" class="year">
|
||||
<h2 class="year-title">{{ y.year }}<span class="count">{{ y.count }} 篇</span></h2>
|
||||
|
||||
<div v-for="m in y.months" :key="y.year + m.month" class="month">
|
||||
<p class="month-label">{{ Number(m.month) }} 月</p>
|
||||
<ul class="list">
|
||||
<li v-for="p in m.posts" :key="p.id">
|
||||
<RouterLink :to="`/post/${p.slug}`" class="row">
|
||||
<span class="date">{{ formatDateShort(p.published_at).slice(5) }}</span>
|
||||
<span class="title">
|
||||
{{ p.kind === 'short' ? '短文' : p.title || '无题' }}
|
||||
<span v-if="p.kind === 'short'" class="badge">短</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.year {
|
||||
padding: 20px 16px 0;
|
||||
}
|
||||
|
||||
.year-title {
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.month {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
padding: 6px 8px;
|
||||
margin: 0 -8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--paper-sunken);
|
||||
}
|
||||
|
||||
.date {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 5px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,78 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 仪表盘主页:统计卡片 + 最近文章
|
||||
import { computed } from 'vue'
|
||||
|
||||
import DashboardMain from '@/components/dashboard/DashboardMain.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: 'Posts',
|
||||
value: String(allPosts.length),
|
||||
icon: 'i-mingcute-news-line',
|
||||
},
|
||||
{
|
||||
label: 'Total Views',
|
||||
value: allPosts.reduce((sum, p) => sum + p.views, 0).toLocaleString(),
|
||||
icon: 'i-mingcute-eye-line',
|
||||
},
|
||||
{
|
||||
label: 'Followers',
|
||||
value: '1,284',
|
||||
icon: 'i-mingcute-user-follow-line',
|
||||
},
|
||||
{
|
||||
label: 'CSB Balance',
|
||||
value: '1,024',
|
||||
icon: 'i-mingcute-pig-money-line',
|
||||
},
|
||||
])
|
||||
|
||||
const recent = computed(() =>
|
||||
[...allPosts]
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.slice(0, 5),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardMain title="Dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="border rounded-xl p-4 flex items-center space-x-3"
|
||||
>
|
||||
<span class="size-10 rounded-lg bg-accent/10 text-accent flex items-center justify-center shrink-0">
|
||||
<i :class="stat.icon" class="text-xl" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-2xl font-bold">{{ stat.value }}</div>
|
||||
<div class="text-zinc-500 text-sm truncate">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近文章 -->
|
||||
<div class="mt-8">
|
||||
<h3 class="font-bold text-lg mb-3">Recent Posts</h3>
|
||||
<div class="border rounded-xl divide-y">
|
||||
<RouterLink
|
||||
v-for="post in recent"
|
||||
:key="post.id"
|
||||
:to="`/post/${post.siteId}/${post.slug}`"
|
||||
class="flex items-center px-4 py-3 hover:bg-zinc-50 transition-colors"
|
||||
>
|
||||
<img
|
||||
:src="post.cover"
|
||||
:alt="post.title"
|
||||
class="w-12 h-8 rounded object-cover mr-3 shrink-0"
|
||||
/>
|
||||
<span class="flex-1 min-w-0 truncate font-medium">{{ post.title }}</span>
|
||||
<span class="text-zinc-400 text-sm ml-3 shrink-0">{{ post.views }} views</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardMain>
|
||||
</template>
|
||||
@@ -1,45 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 简易 Markdown 编辑器(占位实现,仅用于文章管理页跳转)
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import DashboardMain from '@/components/dashboard/DashboardMain.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const post = computed(() => {
|
||||
const id = route.query.id as string | undefined
|
||||
return allPosts.find((p) => p.id === id) ?? allPosts[0]
|
||||
})
|
||||
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
watch(
|
||||
post,
|
||||
(p) => {
|
||||
if (p) {
|
||||
title.value = p.title
|
||||
content.value = p.content
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardMain title="Editor">
|
||||
<div class="space-y-4 max-w-screen-lg">
|
||||
<input v-model="title" class="input is-block text-lg font-bold" placeholder="Title" />
|
||||
<textarea
|
||||
v-model="content"
|
||||
class="input is-block min-h-[400px] py-3 font-mono text-sm resize-y"
|
||||
placeholder="Write in Markdown..."
|
||||
/>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button class="button is-secondary">Save Draft</button>
|
||||
<button class="button is-primary">Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardMain>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 主页:活动标签 + 文章 Grid + 右侧边栏
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import HomeActivitiesTabs from '@/components/home/HomeActivitiesTabs.vue'
|
||||
import HomeFeed from '@/components/home/HomeFeed.vue'
|
||||
import HomeSidebar from '@/components/home/HomeSidebar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const feedType = computed(() => {
|
||||
const path = route.path.replace(/^\//, '')
|
||||
if (['shorts', 'latest', 'hottest', 'following'].includes(path)) {
|
||||
return path as 'shorts' | 'latest' | 'hottest' | 'following'
|
||||
}
|
||||
return 'featured'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 主栏 -->
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<HomeActivitiesTabs />
|
||||
<HomeFeed :type="feedType" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏(桌面端显示) -->
|
||||
<HomeSidebar />
|
||||
</template>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import PostCard from '../components/PostCard.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const kind = computed(() => route.query.kind || '')
|
||||
const tag = computed(() => route.query.tag || '')
|
||||
const page = computed(() => Number(route.query.page || 1))
|
||||
const size = computed(() => site.posts_per_page || 10)
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '长文', value: 'long' },
|
||||
{ label: '短文', value: 'short' }
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await publicApi.posts({
|
||||
kind: kind.value,
|
||||
tag: tag.value,
|
||||
page: page.value,
|
||||
size: size.value
|
||||
})
|
||||
items.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch([kind, tag, page], load)
|
||||
|
||||
function setKind(value) {
|
||||
router.push({ path: '/', query: { ...(value ? { kind: value } : {}), ...(tag.value ? { tag: tag.value } : {}) } })
|
||||
}
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
|
||||
|
||||
const heading = computed(() => {
|
||||
if (tag.value) return `#${tag.value}`
|
||||
if (kind.value === 'long') return '长文'
|
||||
if (kind.value === 'short') return '短文'
|
||||
return '时间线'
|
||||
})
|
||||
|
||||
applyDocTitle('')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="sticky-head">
|
||||
<h1 class="page-title">{{ heading }}</h1>
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="t in tabs"
|
||||
:key="t.value"
|
||||
class="tab"
|
||||
:class="{ active: kind === t.value }"
|
||||
@click="setKind(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
<a v-if="tag" class="tab clear" href="#" @click.prevent="router.push({ path: '/' })">
|
||||
清除标签 ×
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}(后端是否已启动?)</div>
|
||||
<div v-else-if="!items.length" class="empty">
|
||||
这里还空着。<RouterLink to="/admin">去后台写第一篇 →</RouterLink>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<PostCard v-for="p in items" :key="p.id" :post="p" />
|
||||
|
||||
<nav v-if="pageCount > 1" class="pager">
|
||||
<RouterLink
|
||||
v-if="page > 1"
|
||||
class="btn"
|
||||
:to="{ path: '/', query: { ...route.query, page: page - 1 } }"
|
||||
>
|
||||
← 上一页
|
||||
</RouterLink>
|
||||
<span class="page-info">第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<RouterLink
|
||||
v-if="page < pageCount"
|
||||
class="btn"
|
||||
:to="{ path: '/', query: { ...route.query, page: page + 1 } }"
|
||||
>
|
||||
下一页 →
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.sticky-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
padding: 8px 14px 10px;
|
||||
background: none;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--ink);
|
||||
background: var(--paper-sunken);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tab.clear {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 22px 16px 0;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 404 页
|
||||
import Logo from '@/components/common/Logo.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-white flex flex-col items-center justify-center py-20 space-y-6">
|
||||
<div class="inline-block size-16">
|
||||
<Logo :size="64" />
|
||||
</div>
|
||||
<h1 class="text-6xl font-extrabold">404</h1>
|
||||
<p class="text-zinc-500">This page could not be found.</p>
|
||||
<RouterLink to="/" class="button is-primary">Back to Home</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
<main class="main">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow">404</p>
|
||||
<h1 class="title">这一页不存在</h1>
|
||||
<p class="sub">链接可能已经失效,或者文章被撤回了。</p>
|
||||
<RouterLink to="/" class="pill pill-ghost">回到时间线</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 60px 26px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 页面管理页
|
||||
import PagesManager from '@/components/dashboard/PagesManager.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagesManager title="Pages" item-label="Page" />
|
||||
</template>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 文章详情页:标题 + 元信息 + AI 摘要 + Markdown 正文 + 互动区 + 评论
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import MarkdownContent from '@/components/common/MarkdownContent.vue'
|
||||
import PostFooter from '@/components/site/PostFooter.vue'
|
||||
import PostMeta from '@/components/site/PostMeta.vue'
|
||||
import PostTitle from '@/components/site/PostTitle.vue'
|
||||
import { getPost } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const post = computed(() =>
|
||||
getPost(route.params.site as string, route.params.slug as string),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-md mx-auto">
|
||||
<article v-if="post">
|
||||
<!-- 文章标题 -->
|
||||
<PostTitle :post="post" />
|
||||
|
||||
<!-- 文章元信息 -->
|
||||
<PostMeta :post="post" />
|
||||
|
||||
<!-- AI 摘要 -->
|
||||
<div class="xlog-post-summary border rounded-xl mt-5 p-4 space-y-2">
|
||||
<div class="font-bold text-zinc-700 flex items-center">
|
||||
<i class="i-mingcute-sparkles-line mr-2 text-lg" />
|
||||
AI-generated summary
|
||||
</div>
|
||||
<div class="text-zinc-500 leading-loose text-sm">{{ post.excerpt }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown 正文 -->
|
||||
<MarkdownContent :content="post.content" class="mt-10" />
|
||||
|
||||
<!-- 互动区 + 评论区 -->
|
||||
<PostFooter :post="post" />
|
||||
</article>
|
||||
<EmptyState v-else icon="i-mingcute-file-line" text="Post not found" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
import { formatDate, minutesLabel } from '../utils'
|
||||
|
||||
const route = useRoute()
|
||||
const post = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
post.value = null
|
||||
try {
|
||||
post.value = await publicApi.post(route.params.slug)
|
||||
applyDocTitle(post.value.kind === 'short' ? '短文' : post.value.title)
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(() => route.params.slug, load)
|
||||
|
||||
const isShort = computed(() => post.value && post.value.kind === 'short')
|
||||
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
|
||||
<div v-else-if="error" class="empty">
|
||||
{{ error }}
|
||||
<p><RouterLink to="/">← 回到时间线</RouterLink></p>
|
||||
</div>
|
||||
|
||||
<article v-else class="wrap" :class="{ short: isShort }">
|
||||
<header class="head">
|
||||
<div class="byline">
|
||||
<div class="avatar">{{ initial }}</div>
|
||||
<div class="who">
|
||||
<span class="author">{{ site.author_name || 'ONE' }}</span>
|
||||
<span class="sub">
|
||||
<time :datetime="post.published_at">{{ formatDate(post.published_at) }}</time>
|
||||
<span class="dot">·</span>
|
||||
<span>{{ minutesLabel(post) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 短文不渲染大标题 -->
|
||||
<h1 v-if="!isShort" class="title">{{ post.title }}</h1>
|
||||
<p v-if="!isShort && post.summary" class="lede">{{ post.summary }}</p>
|
||||
</header>
|
||||
|
||||
<div class="prose" :class="{ 'prose-short': isShort }" v-html="post.content_html"></div>
|
||||
|
||||
<footer class="foot">
|
||||
<div v-if="post.tags && post.tags.length" class="tags">
|
||||
<RouterLink
|
||||
v-for="t in post.tags"
|
||||
:key="t"
|
||||
:to="`/tag/${encodeURIComponent(t)}`"
|
||||
class="tag-chip"
|
||||
>
|
||||
{{ t }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<RouterLink to="/" class="back">← 回到时间线</RouterLink>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 26px 26px 40px;
|
||||
}
|
||||
|
||||
.head {
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.byline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--serif);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dot {
|
||||
margin: 0 5px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 20px 0 0;
|
||||
font-size: 32px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 10px 0 0;
|
||||
font-family: var(--serif);
|
||||
font-size: 16.5px;
|
||||
color: var(--ink-soft);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.foot {
|
||||
margin-top: 32px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.back {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
padding: 18px 4px 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 27px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 文章管理页
|
||||
import PagesManager from '@/components/dashboard/PagesManager.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagesManager title="Posts" item-label="Post" />
|
||||
</template>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 搜索页:基于 URL query 过滤 mock 文章
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import PostCard from '@/components/common/PostCard.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const keyword = computed(() => String(route.query.q ?? '').trim().toLowerCase())
|
||||
|
||||
const results = computed(() => {
|
||||
if (!keyword.value) return []
|
||||
return allPosts.filter((post) =>
|
||||
[post.title, post.excerpt, ...post.tags, post.author.name]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword.value),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<div class="flex mb-8 border-b">
|
||||
<h2 class="inline-flex items-center h-10 whitespace-nowrap cursor-pointer text-accent font-medium border-b-2 border-accent">
|
||||
Results for "{{ keyword }}"
|
||||
</h2>
|
||||
</div>
|
||||
<div v-if="results.length" class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-3 my-8">
|
||||
<PostCard v-for="post in results" :key="post.id" :post="post" />
|
||||
</div>
|
||||
<EmptyState v-else :text="keyword ? 'No results found' : 'Type something to search'" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,77 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 设置页:General / Social / Navigation / Domains / Custom CSS
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Tabs from '@/components/ui/Tabs.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const tab = computed(() => (route.params.tab as string) || 'general')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'general', label: 'General', to: '/dashboard/settings/general' },
|
||||
{ key: 'social', label: 'Social Platforms', to: '/dashboard/settings/social' },
|
||||
{ key: 'navigation', label: 'Navigation', to: '/dashboard/settings/navigation' },
|
||||
{ key: 'domains', label: 'Domains', to: '/dashboard/settings/domains' },
|
||||
{ key: 'css', label: 'Custom CSS', to: '/dashboard/settings/css' },
|
||||
]
|
||||
|
||||
const form = ref({ name: 'MyBlog', description: 'A blog about code and life.' })
|
||||
const saved = ref(false)
|
||||
|
||||
watch(form, () => (saved.value = false), { deep: true })
|
||||
|
||||
function save() {
|
||||
saved.value = true
|
||||
window.setTimeout(() => (saved.value = false), 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-[270px] relative p-5 md:px-10 md:py-8 min-h-full flex flex-col">
|
||||
<header class="mb-8">
|
||||
<h2 class="text-2xl font-bold">Settings</h2>
|
||||
</header>
|
||||
<div>
|
||||
<!-- 设置子标签 -->
|
||||
<Tabs :items="tabs" :active-key="tab" />
|
||||
|
||||
<!-- 设置内容 -->
|
||||
<div class="max-w-screen-md">
|
||||
<!-- General -->
|
||||
<div v-if="tab === 'general'" class="space-y-6">
|
||||
<div>
|
||||
<label class="block mb-2 font-bold text-gray-700">Site Name</label>
|
||||
<input v-model="form.name" class="input is-block" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 font-bold text-gray-700">Description</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="input is-block min-h-[74px] py-3 resize-y"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-2 font-bold text-gray-700">Avatar</label>
|
||||
<button class="button is-secondary">
|
||||
<i class="i-mingcute-camera-line mr-2" />
|
||||
Upload Avatar
|
||||
</button>
|
||||
</div>
|
||||
<button class="button is-primary" @click="save">
|
||||
{{ saved ? 'Saved ✓' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 其他设置(占位) -->
|
||||
<EmptyState
|
||||
v-else
|
||||
:icon="'i-mingcute-settings-3-line'"
|
||||
:text="`${tab} settings are coming soon`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,48 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 站点归档页:按月份分组的文章列表
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Time from '@/components/common/Time.vue'
|
||||
import { allPosts } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const grouped = computed(() => {
|
||||
const posts = allPosts
|
||||
.filter((p) => p.siteId === route.params.site)
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
|
||||
const map = new Map<string, typeof posts>()
|
||||
for (const post of posts) {
|
||||
const key = post.date.slice(0, 7) // YYYY-MM
|
||||
map.set(key, [...(map.get(key) ?? []), post])
|
||||
}
|
||||
return [...map.entries()]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-screen-md mx-auto">
|
||||
<div v-if="grouped.length">
|
||||
<div v-for="[month, posts] in grouped" :key="month" class="mb-8">
|
||||
<h3 class="text-xl font-bold text-zinc-900 mb-3">{{ month }}</h3>
|
||||
<ul class="border-t divide-y">
|
||||
<li v-for="post in posts" :key="post.id">
|
||||
<RouterLink
|
||||
:to="`/post/${post.siteId}/${post.slug}`"
|
||||
class="flex items-center justify-between py-3 hover:text-accent transition-colors"
|
||||
>
|
||||
<span class="truncate">{{ post.title }}</span>
|
||||
<span class="text-zinc-400 text-sm ml-3 shrink-0">
|
||||
<Time :date="post.date" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else text="No posts yet" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 站点主页:该创作者的公开文章列表
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import PostCard from '@/components/common/PostCard.vue'
|
||||
import { allPosts, sites } from '@/mock/data'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const site = computed(
|
||||
() => sites.find((s) => s.handle === (route.params.site as string)) ?? null,
|
||||
)
|
||||
|
||||
const posts = computed(() => allPosts.filter((p) => p.siteId === route.params.site))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="site">
|
||||
<div
|
||||
v-if="posts.length"
|
||||
class="grid gap-3 sm:gap-6 grid-cols-1 sm:grid-cols-2 my-8"
|
||||
>
|
||||
<PostCard v-for="post in posts" :key="post.id" :post="post" />
|
||||
</div>
|
||||
<EmptyState v-else text="No posts yet" />
|
||||
</div>
|
||||
<EmptyState v-else icon="i-mingcute-file-line" text="Site not found" />
|
||||
</template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import PostCard from '../components/PostCard.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { applyDocTitle } from '../site'
|
||||
|
||||
const route = useRoute()
|
||||
const items = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const size = 20
|
||||
|
||||
const name = computed(() => route.params.slug)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await publicApi.posts({ tag: name.value, page: 1, size })
|
||||
items.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(name, load)
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
|
||||
const page = computed(() => Number(route.query.page || 1))
|
||||
|
||||
watch(page, load)
|
||||
|
||||
applyDocTitle('标签')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="head">
|
||||
<h1 class="page-title">#{{ name }}</h1>
|
||||
<p class="sub">{{ total }} 篇 · <RouterLink to="/tags">全部标签</RouterLink></p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!items.length" class="empty">这个标签下还没有文章。</div>
|
||||
|
||||
<template v-else>
|
||||
<PostCard v-for="p in items" :key="p.id" :post="p" />
|
||||
<nav v-if="pageCount > 1" class="pager">
|
||||
<RouterLink
|
||||
v-if="page > 1"
|
||||
class="btn"
|
||||
:to="{ path: `/tag/${name}`, query: { page: page - 1 } }"
|
||||
>
|
||||
← 上一页
|
||||
</RouterLink>
|
||||
<span class="page-info">第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<RouterLink
|
||||
v-if="page < pageCount"
|
||||
class="btn"
|
||||
:to="{ path: `/tag/${name}`, query: { page: page + 1 } }"
|
||||
>
|
||||
下一页 →
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 22px 16px 0;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import LeftNav from '../components/LeftNav.vue'
|
||||
import RightRail from '../components/RightRail.vue'
|
||||
import { publicApi } from '../api'
|
||||
import { applyDocTitle } from '../site'
|
||||
|
||||
const tags = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await publicApi.tags()
|
||||
tags.value = data.tags || []
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const max = () => Math.max(1, ...tags.value.map((t) => t.count))
|
||||
|
||||
applyDocTitle('标签')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
<LeftNav />
|
||||
|
||||
<main class="main">
|
||||
<header class="head">
|
||||
<h1 class="page-title">标签</h1>
|
||||
<p class="sub">共 {{ tags.length }} 个标签。</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading">载入中…</div>
|
||||
<div v-else-if="error" class="empty">{{ error }}</div>
|
||||
<div v-else-if="!tags.length" class="empty">还没有标签。</div>
|
||||
|
||||
<div v-else class="cloud">
|
||||
<RouterLink
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
:to="`/tag/${t.slug}`"
|
||||
class="tag"
|
||||
:style="{ fontSize: 14 + Math.round((t.count / max()) * 8) + 'px' }"
|
||||
>
|
||||
{{ t.name }}<span class="count">{{ t.count }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<RightRail />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(250, 247, 241, 0.94);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
padding: 22px 16px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: var(--ink-soft);
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent-line);
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.main {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,8 @@ import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const target = process.env.ONE_API || 'http://localhost:8080'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
@@ -12,5 +14,14 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': { target, changeOrigin: true },
|
||||
'/rss.xml': { target, changeOrigin: true },
|
||||
'/feed': { target, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user