feat: add admin publishing workflow and yar theme
Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -14,6 +14,13 @@ dist/
|
|||
# Node
|
||||
node_modules/
|
||||
.astro/
|
||||
frontend/admin/.angular/
|
||||
|
||||
# Go
|
||||
*.test
|
||||
backend/.cache/
|
||||
backend/import-articles
|
||||
backend/osaetctl
|
||||
|
||||
# Local import data
|
||||
articles.csv
|
||||
|
|
|
|||
33
backend/cmd/import-articles/main.go
Normal file
33
backend/cmd/import-articles/main.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"osaet/backend/internal/postimport"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fs := flag.NewFlagSet("import-articles", flag.ExitOnError)
|
||||
file := fs.String("file", "articles.csv", "CSV file path")
|
||||
overwrite := fs.Bool("overwrite", false, "overwrite existing markdown files")
|
||||
postsDir := fs.String("posts-dir", "content/posts", "posts output directory relative to project root")
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
result, err := postimport.Import(postimport.Options{
|
||||
CSVPath: *file,
|
||||
PostsDir: *postsDir,
|
||||
Overwrite: *overwrite,
|
||||
WorkingDir: "",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("imported %d post(s), skipped %d existing file(s), skipped %d non-post row(s)\n", result.Imported, result.SkippedExisting, result.SkippedNonPost)
|
||||
}
|
||||
116
backend/cmd/osaet-admin/main.go
Normal file
116
backend/cmd/osaet-admin/main.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"osaet/backend/internal/admin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
command := "serve"
|
||||
if len(os.Args) > 1 {
|
||||
command = os.Args[1]
|
||||
}
|
||||
|
||||
cfg := admin.LoadConfig()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := admin.OpenDatabase(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
switch command {
|
||||
case "serve":
|
||||
return serve(ctx, cfg, db)
|
||||
case "migrate":
|
||||
return admin.RunMigrations(ctx, db, cfg.MigrationsDir)
|
||||
case "create-user":
|
||||
return createUser(ctx, db)
|
||||
case "import-markdown":
|
||||
return importMarkdown(ctx, cfg, db)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", command)
|
||||
}
|
||||
}
|
||||
|
||||
func createUser(ctx context.Context, db *pgxpool.Pool) error {
|
||||
if len(os.Args) < 3 {
|
||||
return errors.New("usage: osaet-admin create-user <username>")
|
||||
}
|
||||
|
||||
password := os.Getenv("OSAET_ADMIN_PASSWORD")
|
||||
if password == "" {
|
||||
return errors.New("OSAET_ADMIN_PASSWORD is required")
|
||||
}
|
||||
|
||||
user, err := admin.NewStore(db).CreateOrUpdateUser(ctx, os.Args[2], password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "admin user %q is ready\n", user.Username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func importMarkdown(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error {
|
||||
postsDir := cfg.PostsDir
|
||||
if len(os.Args) >= 3 {
|
||||
postsDir = os.Args[2]
|
||||
}
|
||||
|
||||
result, err := admin.NewStore(db).ImportMarkdownPosts(ctx, postsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "imported %d markdown post(s), skipped %d file(s)\n", result.Imported, result.Skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error {
|
||||
server := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: admin.NewServerWithContext(ctx, db, cfg).Router(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(shutdownCtx)
|
||||
case err := <-errCh:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,53 @@
|
|||
module osaet/backend
|
||||
|
||||
go 1.25
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/jackc/pgx/v5 v5.4.3
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.33.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
|
|
|
|||
111
backend/go.sum
111
backend/go.sum
|
|
@ -1,28 +1,125 @@
|
|||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
||||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
|
|
|
|||
80
backend/internal/admin/assets.go
Normal file
80
backend/internal/admin/assets.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxAssetSizeBytes = 12 * 1024 * 1024
|
||||
|
||||
var safeAssetNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
type AssetUploader struct {
|
||||
store *Store
|
||||
assetsDir string
|
||||
}
|
||||
|
||||
func NewAssetUploader(store *Store, assetsDir string) *AssetUploader {
|
||||
return &AssetUploader{store: store, assetsDir: assetsDir}
|
||||
}
|
||||
|
||||
func (u *AssetUploader) Upload(ctx context.Context, file multipart.File, header *multipart.FileHeader) (Asset, error) {
|
||||
if header.Size > maxAssetSizeBytes {
|
||||
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxAssetSizeBytes+1))
|
||||
if err != nil {
|
||||
return Asset{}, fmt.Errorf("read asset: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxAssetSizeBytes {
|
||||
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(data)
|
||||
if !strings.HasPrefix(mimeType, "image/") {
|
||||
return Asset{}, fmt.Errorf("unsupported asset type: %s", mimeType)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
sha := hex.EncodeToString(sum[:])
|
||||
name := assetFilename(header.Filename, sha)
|
||||
if err := os.MkdirAll(u.assetsDir, 0o755); err != nil {
|
||||
return Asset{}, fmt.Errorf("create assets dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(u.assetsDir, name), data, 0o644); err != nil {
|
||||
return Asset{}, fmt.Errorf("write asset: %w", err)
|
||||
}
|
||||
|
||||
asset := Asset{
|
||||
Path: "/assets/" + name,
|
||||
OriginalName: header.Filename,
|
||||
MimeType: mimeType,
|
||||
SizeBytes: int64(len(data)),
|
||||
SHA256: sha,
|
||||
}
|
||||
return u.store.CreateAsset(ctx, asset)
|
||||
}
|
||||
|
||||
func assetFilename(original string, sha string) string {
|
||||
ext := strings.ToLower(filepath.Ext(original))
|
||||
base := strings.TrimSuffix(filepath.Base(original), filepath.Ext(original))
|
||||
base = strings.Trim(safeAssetNamePattern.ReplaceAllString(base, "-"), "-._")
|
||||
if base == "" {
|
||||
base = "image"
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
return fmt.Sprintf("%s-%s%s", base, sha[:12], ext)
|
||||
}
|
||||
75
backend/internal/admin/auth.go
Normal file
75
backend/internal/admin/auth.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const SessionCookieName = "osaet_admin_session"
|
||||
|
||||
var ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "adminUser"
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if password == "" {
|
||||
return "", errors.New("password is required")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func CheckPassword(hash string, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func NewSessionToken() (string, error) {
|
||||
token := make([]byte, 32)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return "", fmt.Errorf("generate session token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(token), nil
|
||||
}
|
||||
|
||||
func SessionTokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func SetSessionCookie(c *gin.Context, token string, expiresAt time.Time) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func ClearSessionCookie(c *gin.Context) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
166
backend/internal/admin/auth_store.go
Normal file
166
backend/internal/admin/auth_store.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const sessionLifetime = 30 * 24 * time.Hour
|
||||
|
||||
type LoginInput struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
User User `json:"user"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Token string `json:"-"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateOrUpdateUser(ctx context.Context, username string, password string) (User, error) {
|
||||
if username == "" {
|
||||
return User{}, errors.New("username is required")
|
||||
}
|
||||
passwordHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
user, err := scanUser(s.db.QueryRow(ctx, `
|
||||
INSERT INTO users (username, password_hash)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (username)
|
||||
DO UPDATE SET password_hash = excluded.password_hash, updated_at = now()
|
||||
RETURNING id, username, created_at, updated_at, last_login_at`, username, passwordHash))
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("create or update user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
|
||||
if input.Username == "" || input.Password == "" {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
var user User
|
||||
var passwordHash string
|
||||
var lastLoginAt sql.NullTime
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, username, password_hash, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE username = $1`, input.Username).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&passwordHash,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
&lastLoginAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, fmt.Errorf("find user: %w", err)
|
||||
}
|
||||
user.LastLoginAt = nullTimePtr(lastLoginAt)
|
||||
|
||||
if !CheckPassword(passwordHash, input.Password) {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
token, err := NewSessionToken()
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
loginAt := time.Now()
|
||||
expiresAt := loginAt.Add(sessionLifetime)
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return LoginResult{}, fmt.Errorf("begin login: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET last_login_at = now(), updated_at = now()
|
||||
WHERE id = $1`, user.ID); err != nil {
|
||||
return LoginResult{}, fmt.Errorf("update last login: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_sessions (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)`, user.ID, SessionTokenHash(token), expiresAt); err != nil {
|
||||
return LoginResult{}, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return LoginResult{}, fmt.Errorf("commit login: %w", err)
|
||||
}
|
||||
|
||||
user.LastLoginAt = &loginAt
|
||||
return LoginResult{
|
||||
User: user,
|
||||
ExpiresAt: expiresAt,
|
||||
Token: token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) UserBySessionToken(ctx context.Context, token string) (User, error) {
|
||||
if token == "" {
|
||||
return User{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
user, err := scanUser(s.db.QueryRow(ctx, `
|
||||
SELECT u.id, u.username, u.created_at, u.updated_at, u.last_login_at
|
||||
FROM admin_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.expires_at > now()`, SessionTokenHash(token)))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("find session: %w", err)
|
||||
}
|
||||
|
||||
_, _ = s.db.Exec(ctx, `
|
||||
UPDATE admin_sessions
|
||||
SET last_seen_at = now()
|
||||
WHERE token_hash = $1`, SessionTokenHash(token))
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) Logout(ctx context.Context, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
DELETE FROM admin_sessions
|
||||
WHERE token_hash = $1`, SessionTokenHash(token)); err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanUser(row postScanner) (User, error) {
|
||||
var user User
|
||||
var lastLoginAt sql.NullTime
|
||||
err := row.Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
&lastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user.LastLoginAt = nullTimePtr(lastLoginAt)
|
||||
return user, nil
|
||||
}
|
||||
91
backend/internal/admin/builder.go
Normal file
91
backend/internal/admin/builder.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Builder struct {
|
||||
store *Store
|
||||
exporter *Exporter
|
||||
siteDir string
|
||||
queue chan string
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func NewBuilder(store *Store, exporter *Exporter, siteDir string) *Builder {
|
||||
return &Builder{
|
||||
store: store,
|
||||
exporter: exporter,
|
||||
siteDir: siteDir,
|
||||
queue: make(chan string, 32),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Builder) Start(ctx context.Context) {
|
||||
b.once.Do(func() {
|
||||
go b.loop(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Builder) Enqueue(jobID string) bool {
|
||||
select {
|
||||
case b.queue <- jobID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Builder) loop(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case jobID := <-b.queue:
|
||||
b.runBuildJob(ctx, jobID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Builder) runBuildJob(ctx context.Context, jobID string) {
|
||||
log, err := b.run(ctx, jobID)
|
||||
if err != nil {
|
||||
_ = b.store.MarkBuildJobFailed(context.Background(), jobID, log, err.Error())
|
||||
return
|
||||
}
|
||||
_ = b.store.MarkBuildJobSuccess(context.Background(), jobID, log)
|
||||
}
|
||||
|
||||
func (b *Builder) run(ctx context.Context, jobID string) (string, error) {
|
||||
if err := b.store.MarkBuildJobRunning(ctx, jobID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
posts, err := b.store.PublishedPostsForExport(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := b.exporter.ExportPublishedPosts(ctx, posts); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
buildCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(buildCtx, "npm", "run", "build")
|
||||
cmd.Dir = b.siteDir
|
||||
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return output.String(), fmt.Errorf("astro build failed: %w", err)
|
||||
}
|
||||
|
||||
return output.String(), nil
|
||||
}
|
||||
243
backend/internal/admin/config.go
Normal file
243
backend/internal/admin/config.go
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultAdminAddr = ":8080"
|
||||
const defaultMigrationsDir = "migrations"
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DatabaseURL string
|
||||
MigrationsDir string
|
||||
RepoRoot string
|
||||
PostsDir string
|
||||
SiteDir string
|
||||
AssetsDir string
|
||||
AdminDir string
|
||||
DeepSeek DeepSeekConfig
|
||||
LocalLLM LocalLLMConfig
|
||||
SlugProvider string
|
||||
}
|
||||
|
||||
type DeepSeekConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
}
|
||||
|
||||
type LocalLLMConfig struct {
|
||||
URL string
|
||||
Model string
|
||||
Temperature float64
|
||||
TopP float64
|
||||
NumPredict int
|
||||
}
|
||||
|
||||
type localConfig struct {
|
||||
Database struct {
|
||||
PostgresDSN string `yaml:"postgres_dsn"`
|
||||
} `yaml:"database"`
|
||||
Slug struct {
|
||||
Provider string `yaml:"provider"`
|
||||
} `yaml:"slug"`
|
||||
DeepSeek struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
APIKeyEnv string `yaml:"api_key_env"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Model string `yaml:"model"`
|
||||
} `yaml:"deepseek"`
|
||||
LocalLLM struct {
|
||||
URL string `yaml:"url"`
|
||||
Model string `yaml:"model"`
|
||||
Temperature float64 `yaml:"temperature"`
|
||||
TopP float64 `yaml:"top_p"`
|
||||
NumPredict int `yaml:"num_predict"`
|
||||
} `yaml:"local_llm"`
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
addr := os.Getenv("OSAET_ADMIN_ADDR")
|
||||
if addr == "" {
|
||||
addr = defaultAdminAddr
|
||||
}
|
||||
|
||||
migrationsDir := os.Getenv("OSAET_MIGRATIONS_DIR")
|
||||
if migrationsDir == "" {
|
||||
migrationsDir = defaultMigrationsDir
|
||||
}
|
||||
|
||||
repoRoot := os.Getenv("OSAET_REPO_ROOT")
|
||||
if repoRoot == "" {
|
||||
repoRoot = ".."
|
||||
}
|
||||
|
||||
postsDir := os.Getenv("OSAET_POSTS_DIR")
|
||||
if postsDir == "" {
|
||||
postsDir = filepath.Join(repoRoot, "content", "posts")
|
||||
}
|
||||
|
||||
siteDir := os.Getenv("OSAET_SITE_DIR")
|
||||
if siteDir == "" {
|
||||
siteDir = filepath.Join(repoRoot, "frontend", "site")
|
||||
}
|
||||
|
||||
assetsDir := os.Getenv("OSAET_ASSETS_DIR")
|
||||
if assetsDir == "" {
|
||||
assetsDir = filepath.Join(siteDir, "public", "assets")
|
||||
}
|
||||
|
||||
adminDir := os.Getenv("OSAET_ADMIN_DIR")
|
||||
if adminDir == "" {
|
||||
adminDir = filepath.Join(repoRoot, "frontend", "admin", "dist", "admin", "browser")
|
||||
}
|
||||
|
||||
local := loadLocalConfig(repoRoot)
|
||||
databaseURL := firstNonEmpty(os.Getenv("DATABASE_URL"), local.Database.PostgresDSN)
|
||||
|
||||
deepSeekAPIKeyEnv := firstNonEmpty(local.DeepSeek.APIKeyEnv, "DEEPSEEK_API_KEY")
|
||||
deepSeekAPIKey := strings.TrimSpace(os.Getenv(deepSeekAPIKeyEnv))
|
||||
if deepSeekAPIKey == "" && deepSeekAPIKeyEnv != "DEEPSEEK_API_KEY" {
|
||||
deepSeekAPIKey = strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY"))
|
||||
}
|
||||
if deepSeekAPIKey == "" {
|
||||
deepSeekAPIKey = strings.TrimSpace(local.DeepSeek.APIKey)
|
||||
}
|
||||
|
||||
return Config{
|
||||
Addr: addr,
|
||||
DatabaseURL: databaseURL,
|
||||
MigrationsDir: migrationsDir,
|
||||
RepoRoot: repoRoot,
|
||||
PostsDir: postsDir,
|
||||
SiteDir: siteDir,
|
||||
AssetsDir: assetsDir,
|
||||
AdminDir: adminDir,
|
||||
SlugProvider: firstNonEmpty(os.Getenv("OSAET_SLUG_PROVIDER"), local.Slug.Provider, "deepseek"),
|
||||
DeepSeek: DeepSeekConfig{
|
||||
APIKey: deepSeekAPIKey,
|
||||
BaseURL: firstNonEmpty(os.Getenv("DEEPSEEK_BASE_URL"), local.DeepSeek.BaseURL),
|
||||
Model: firstNonEmpty(os.Getenv("DEEPSEEK_MODEL"), local.DeepSeek.Model),
|
||||
},
|
||||
LocalLLM: LocalLLMConfig{
|
||||
URL: firstNonEmpty(os.Getenv("LOCAL_LLM_URL"), local.LocalLLM.URL, "http://127.0.0.1:11434/api/generate"),
|
||||
Model: firstNonEmpty(os.Getenv("LOCAL_LLM_MODEL"), local.LocalLLM.Model),
|
||||
Temperature: firstNonZeroFloat(envFloat("LOCAL_LLM_TEMPERATURE"), local.LocalLLM.Temperature, 0.1),
|
||||
TopP: firstNonZeroFloat(envFloat("LOCAL_LLM_TOP_P"), local.LocalLLM.TopP, 0.8),
|
||||
NumPredict: firstNonZeroInt(envInt("LOCAL_LLM_NUM_PREDICT"), local.LocalLLM.NumPredict, 32),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadLocalConfig(repoRoot string) localConfig {
|
||||
var config localConfig
|
||||
paths := []string{}
|
||||
if path := strings.TrimSpace(os.Getenv("OSAET_LOCAL_CONFIG")); path != "" {
|
||||
paths = append(paths, path)
|
||||
} else {
|
||||
paths = append(paths,
|
||||
filepath.Join(repoRoot, "config", "local.yaml"),
|
||||
filepath.Join("config", "local.yaml"),
|
||||
filepath.Join("..", "config", "local.yaml"),
|
||||
)
|
||||
}
|
||||
|
||||
path, data, ok := readFirstExistingFile(paths)
|
||||
if !ok {
|
||||
return config
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to read %s: %v\n", path, err)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func readFirstExistingFile(paths []string) (string, []byte, bool) {
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return path, data, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonZeroFloat(values ...float64) float64 {
|
||||
for _, value := range values {
|
||||
if value != 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonZeroInt(values ...int) int {
|
||||
for _, value := range values {
|
||||
if value != 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func envFloat(key string) float64 {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
var parsed float64
|
||||
if _, err := fmt.Sscanf(value, "%f", &parsed); err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envInt(key string) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.Addr == "" {
|
||||
return errors.New("admin addr is required")
|
||||
}
|
||||
if c.DatabaseURL == "" {
|
||||
return errors.New("DATABASE_URL or config/local.yaml database.postgres_dsn is required")
|
||||
}
|
||||
if c.MigrationsDir == "" {
|
||||
return errors.New("migrations dir is required")
|
||||
}
|
||||
if c.PostsDir == "" {
|
||||
return errors.New("posts dir is required")
|
||||
}
|
||||
if c.SiteDir == "" {
|
||||
return errors.New("site dir is required")
|
||||
}
|
||||
if c.AssetsDir == "" {
|
||||
return errors.New("assets dir is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
20
backend/internal/admin/database.go
Normal file
20
backend/internal/admin/database.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func OpenDatabase(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create postgres pool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
135
backend/internal/admin/exporter.go
Normal file
135
backend/internal/admin/exporter.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Exporter struct {
|
||||
postsDir string
|
||||
}
|
||||
|
||||
type postFrontmatter struct {
|
||||
ID string `yaml:"id"`
|
||||
Slug string `yaml:"slug"`
|
||||
Title string `yaml:"title"`
|
||||
Summary string `yaml:"summary"`
|
||||
Status string `yaml:"status"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Cover string `yaml:"cover"`
|
||||
Version int `yaml:"version"`
|
||||
SlugSource string `yaml:"slug_source"`
|
||||
SlugLocked bool `yaml:"slug_locked"`
|
||||
PublishedAt string `yaml:"published_at"`
|
||||
CreatedAt string `yaml:"created_at"`
|
||||
UpdatedAt string `yaml:"updated_at"`
|
||||
}
|
||||
|
||||
func NewExporter(postsDir string) *Exporter {
|
||||
return &Exporter{postsDir: postsDir}
|
||||
}
|
||||
|
||||
func (e *Exporter) ExportPublishedPosts(ctx context.Context, posts []Post) error {
|
||||
if err := os.MkdirAll(e.postsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create posts dir: %w", err)
|
||||
}
|
||||
|
||||
publishedFiles := make(map[string]bool, len(posts))
|
||||
for _, post := range posts {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
publishedFiles[post.Slug+".md"] = true
|
||||
if err := e.writePost(post); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := e.removeStalePosts(publishedFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Exporter) removeStalePosts(publishedFiles map[string]bool) error {
|
||||
entries, err := os.ReadDir(e.postsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read posts dir: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
if publishedFiles[entry.Name()] {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(e.postsDir, entry.Name())); err != nil {
|
||||
return fmt.Errorf("remove stale post %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Exporter) writePost(post Post) error {
|
||||
path := filepath.Join(e.postsDir, post.Slug+".md")
|
||||
frontmatter := postFrontmatter{
|
||||
ID: post.ID,
|
||||
Slug: post.Slug,
|
||||
Title: post.Title,
|
||||
Summary: post.Summary,
|
||||
Status: string(post.Status),
|
||||
Tags: post.Tags,
|
||||
Cover: post.Cover,
|
||||
Version: post.Version,
|
||||
SlugSource: post.SlugSource,
|
||||
SlugLocked: post.SlugLocked,
|
||||
PublishedAt: formatFrontmatterTime(post.PublishedAt),
|
||||
CreatedAt: formatFrontmatterTime(&post.CreatedAt),
|
||||
UpdatedAt: formatFrontmatterTime(&post.UpdatedAt),
|
||||
}
|
||||
|
||||
var meta bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&meta)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(frontmatter); err != nil {
|
||||
return fmt.Errorf("encode frontmatter: %w", err)
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return fmt.Errorf("close frontmatter encoder: %w", err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
output.WriteString("---\n")
|
||||
output.Write(meta.Bytes())
|
||||
output.WriteString("---\n\n")
|
||||
output.WriteString(strings.TrimLeft(post.BodyMarkdown, "\n"))
|
||||
if !strings.HasSuffix(output.String(), "\n") {
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
|
||||
return fmt.Errorf("write post %s: %w", post.Slug, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("replace post %s: %w", post.Slug, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatFrontmatterTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
239
backend/internal/admin/markdown_import.go
Normal file
239
backend/internal/admin/markdown_import.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type MarkdownImportResult struct {
|
||||
Imported int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
type markdownFrontmatter struct {
|
||||
Slug string `yaml:"slug"`
|
||||
Title string `yaml:"title"`
|
||||
Summary string `yaml:"summary"`
|
||||
Status string `yaml:"status"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Cover string `yaml:"cover"`
|
||||
Version int `yaml:"version"`
|
||||
SlugSource string `yaml:"slug_source"`
|
||||
SlugLocked bool `yaml:"slug_locked"`
|
||||
PublishedAt string `yaml:"published_at"`
|
||||
CreatedAt string `yaml:"created_at"`
|
||||
UpdatedAt string `yaml:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *Store) ImportMarkdownPosts(ctx context.Context, postsDir string) (MarkdownImportResult, error) {
|
||||
entries, err := os.ReadDir(postsDir)
|
||||
if err != nil {
|
||||
return MarkdownImportResult{}, fmt.Errorf("read posts dir: %w", err)
|
||||
}
|
||||
|
||||
var result MarkdownImportResult
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
post, err := readMarkdownPost(filepath.Join(postsDir, entry.Name()))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if post.Title == "" || post.Slug == "" {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if err := s.upsertImportedPost(ctx, post); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readMarkdownPost(path string) (Post, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("read markdown post %s: %w", path, err)
|
||||
}
|
||||
|
||||
frontmatterData, body, err := splitMarkdownFrontmatter(data)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("parse markdown post %s: %w", path, err)
|
||||
}
|
||||
|
||||
var meta markdownFrontmatter
|
||||
if err := yaml.Unmarshal(frontmatterData, &meta); err != nil {
|
||||
return Post{}, fmt.Errorf("parse frontmatter %s: %w", path, err)
|
||||
}
|
||||
|
||||
slug := strings.TrimSpace(meta.Slug)
|
||||
if slug == "" {
|
||||
slug = strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
}
|
||||
status := PostStatus(strings.TrimSpace(meta.Status))
|
||||
if !ValidPostStatus(status) || status == PostStatusDeleted {
|
||||
status = PostStatusDraft
|
||||
}
|
||||
version := meta.Version
|
||||
if version < 1 {
|
||||
version = 1
|
||||
}
|
||||
slugSource := strings.TrimSpace(meta.SlugSource)
|
||||
if slugSource == "" {
|
||||
slugSource = "manual"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
createdAt := parseFrontmatterTime(meta.CreatedAt, now)
|
||||
updatedAt := parseFrontmatterTime(meta.UpdatedAt, createdAt)
|
||||
var publishedAt *time.Time
|
||||
if parsed, ok := parseOptionalFrontmatterTime(meta.PublishedAt); ok {
|
||||
publishedAt = &parsed
|
||||
}
|
||||
|
||||
return Post{
|
||||
Slug: slug,
|
||||
Title: strings.TrimSpace(meta.Title),
|
||||
Summary: meta.Summary,
|
||||
BodyMarkdown: strings.TrimLeft(string(body), "\n"),
|
||||
Status: status,
|
||||
Tags: normalizeTagNames(meta.Tags),
|
||||
Cover: meta.Cover,
|
||||
Version: version,
|
||||
SlugSource: slugSource,
|
||||
SlugLocked: meta.SlugLocked,
|
||||
PublishedAt: publishedAt,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) upsertImportedPost(ctx context.Context, post Post) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin markdown import: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
imported, err := scanPost(tx.QueryRow(ctx, `
|
||||
INSERT INTO posts (
|
||||
slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (slug)
|
||||
DO UPDATE SET
|
||||
title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
body_markdown = excluded.body_markdown,
|
||||
status = excluded.status,
|
||||
cover = excluded.cover,
|
||||
version = GREATEST(posts.version, excluded.version),
|
||||
slug_source = excluded.slug_source,
|
||||
slug_locked = excluded.slug_locked,
|
||||
published_at = excluded.published_at,
|
||||
updated_at = excluded.updated_at,
|
||||
deleted_at = NULL
|
||||
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
|
||||
post.Slug,
|
||||
post.Title,
|
||||
post.Summary,
|
||||
post.BodyMarkdown,
|
||||
post.Status,
|
||||
post.Cover,
|
||||
post.Version,
|
||||
post.SlugSource,
|
||||
post.SlugLocked,
|
||||
post.PublishedAt,
|
||||
post.CreatedAt,
|
||||
post.UpdatedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert markdown post %s: %w", post.Slug, err)
|
||||
}
|
||||
if err := replacePostTags(ctx, tx, imported.ID, post.Tags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO post_versions (post_id, version, title, summary, body_markdown, status, reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (post_id, version) DO NOTHING`,
|
||||
imported.ID,
|
||||
imported.Version,
|
||||
imported.Title,
|
||||
imported.Summary,
|
||||
imported.BodyMarkdown,
|
||||
imported.Status,
|
||||
VersionReasonImport,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert markdown import version %s: %w", post.Slug, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit markdown import: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitMarkdownFrontmatter(data []byte) ([]byte, []byte, error) {
|
||||
if !bytes.HasPrefix(data, []byte("---\n")) {
|
||||
return nil, nil, errors.New("missing frontmatter opening marker")
|
||||
}
|
||||
|
||||
rest := data[len("---\n"):]
|
||||
idx := bytes.Index(rest, []byte("\n---"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("missing frontmatter closing marker")
|
||||
}
|
||||
|
||||
frontmatter := rest[:idx]
|
||||
body := rest[idx+len("\n---"):]
|
||||
if bytes.HasPrefix(body, []byte("\r\n")) {
|
||||
body = body[2:]
|
||||
} else if bytes.HasPrefix(body, []byte("\n")) {
|
||||
body = body[1:]
|
||||
}
|
||||
return frontmatter, body, nil
|
||||
}
|
||||
|
||||
func parseFrontmatterTime(value string, fallback time.Time) time.Time {
|
||||
if parsed, ok := parseOptionalFrontmatterTime(value); ok {
|
||||
return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseOptionalFrontmatterTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05.999999999Z07",
|
||||
"2006-01-02 15:04:05.999999Z07",
|
||||
"2006-01-02 15:04:05Z07",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
128
backend/internal/admin/migrations.go
Normal file
128
backend/internal/admin/migrations.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
Version string
|
||||
Path string
|
||||
Checksum string
|
||||
}
|
||||
|
||||
func RunMigrations(ctx context.Context, db *pgxpool.Pool, dir string) error {
|
||||
if db == nil {
|
||||
return errors.New("database is required")
|
||||
}
|
||||
|
||||
migrations, err := LoadMigrationFiles(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS admin_schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("ensure migration table: %w", err)
|
||||
}
|
||||
|
||||
for _, migration := range migrations {
|
||||
if err := applyMigration(ctx, tx, migration); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadMigrationFiles(dir string) ([]Migration, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
var migrations []Migration
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
migrations = append(migrations, Migration{
|
||||
Version: entry.Name(),
|
||||
Path: path,
|
||||
Checksum: checksum(content),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(migrations, func(i, j int) bool {
|
||||
return migrations[i].Version < migrations[j].Version
|
||||
})
|
||||
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, tx pgx.Tx, migration Migration) error {
|
||||
var appliedChecksum string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT checksum
|
||||
FROM admin_schema_migrations
|
||||
WHERE version = $1`, migration.Version).Scan(&appliedChecksum)
|
||||
|
||||
if err == nil {
|
||||
if appliedChecksum != migration.Checksum {
|
||||
return fmt.Errorf("migration %s checksum changed", migration.Version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("check migration %s: %w", migration.Version, err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(migration.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", migration.Version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(content)); err != nil {
|
||||
return fmt.Errorf("apply migration %s: %w", migration.Version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_schema_migrations (version, checksum)
|
||||
VALUES ($1, $2)`, migration.Version, migration.Checksum); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", migration.Version, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checksum(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
35
backend/internal/admin/migrations_test.go
Normal file
35
backend/internal/admin/migrations_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadMigrationFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"002_second.sql": "select 2;",
|
||||
"001_first.sql": "select 1;",
|
||||
"notes.txt": "ignored",
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
migrations, err := LoadMigrationFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load migrations: %v", err)
|
||||
}
|
||||
if len(migrations) != 2 {
|
||||
t.Fatalf("expected 2 migrations, got %d", len(migrations))
|
||||
}
|
||||
if migrations[0].Version != "001_first.sql" || migrations[1].Version != "002_second.sql" {
|
||||
t.Fatalf("unexpected migration order: %#v", migrations)
|
||||
}
|
||||
if migrations[0].Checksum == "" || migrations[0].Checksum == migrations[1].Checksum {
|
||||
t.Fatalf("unexpected checksums: %#v", migrations)
|
||||
}
|
||||
}
|
||||
474
backend/internal/admin/router.go
Normal file
474
backend/internal/admin/router.go
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"osaet/backend/internal/ai"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
db *pgxpool.Pool
|
||||
store *Store
|
||||
builder *Builder
|
||||
uploader *AssetUploader
|
||||
deepSeek DeepSeekConfig
|
||||
localLLM LocalLLMConfig
|
||||
slugProvider string
|
||||
adminDir string
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func NewServer(db *pgxpool.Pool) *Server {
|
||||
return NewServerWithConfig(db, Config{})
|
||||
}
|
||||
|
||||
func NewServerWithConfig(db *pgxpool.Pool, cfg Config) *Server {
|
||||
return NewServerWithContext(context.Background(), db, cfg)
|
||||
}
|
||||
|
||||
func NewServerWithContext(ctx context.Context, db *pgxpool.Pool, cfg Config) *Server {
|
||||
var store *Store
|
||||
var builder *Builder
|
||||
var uploader *AssetUploader
|
||||
if db != nil {
|
||||
store = NewStore(db)
|
||||
if cfg.PostsDir != "" && cfg.SiteDir != "" {
|
||||
builder = NewBuilder(store, NewExporter(cfg.PostsDir), cfg.SiteDir)
|
||||
builder.Start(ctx)
|
||||
}
|
||||
if cfg.AssetsDir != "" {
|
||||
uploader = NewAssetUploader(store, cfg.AssetsDir)
|
||||
}
|
||||
}
|
||||
return &Server{
|
||||
db: db,
|
||||
store: store,
|
||||
builder: builder,
|
||||
uploader: uploader,
|
||||
deepSeek: cfg.DeepSeek,
|
||||
localLLM: cfg.LocalLLM,
|
||||
slugProvider: cfg.SlugProvider,
|
||||
adminDir: cfg.AdminDir,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
r.GET("/healthz", s.health)
|
||||
r.GET("/readyz", s.ready)
|
||||
r.GET("/admin", s.adminPage)
|
||||
r.GET("/admin/", s.adminPage)
|
||||
r.GET("/admin/:filepath", s.adminFile)
|
||||
|
||||
api := r.Group("/api/admin")
|
||||
api.GET("/health", s.health)
|
||||
api.POST("/login", s.login)
|
||||
|
||||
protected := api.Group("")
|
||||
protected.Use(s.requireAuth)
|
||||
protected.GET("/me", s.me)
|
||||
protected.POST("/logout", s.logout)
|
||||
protected.POST("/assets", s.uploadAsset)
|
||||
protected.POST("/slug", s.generateSlug)
|
||||
protected.GET("/posts", s.listPosts)
|
||||
protected.POST("/posts", s.createPost)
|
||||
protected.GET("/posts/:id", s.getPost)
|
||||
protected.PUT("/posts/:id", s.updatePost)
|
||||
protected.DELETE("/posts/:id", s.deletePost)
|
||||
protected.POST("/posts/:id/build", s.buildPost)
|
||||
protected.POST("/posts/:id/publish", s.publishPost)
|
||||
protected.GET("/build-jobs/:id", s.getBuildJob)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"service": "osaet-admin",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) adminPage(c *gin.Context) {
|
||||
page, err := adminIndex(s.adminDir)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", page)
|
||||
}
|
||||
|
||||
func (s *Server) adminFile(c *gin.Context) {
|
||||
if serveAdminFile(c, s.adminDir) {
|
||||
return
|
||||
}
|
||||
s.adminPage(c)
|
||||
}
|
||||
|
||||
func (s *Server) ready(c *gin.Context) {
|
||||
if s.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"ok": false,
|
||||
"error": "database is not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.db.Ping(ctx); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) listPosts(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
opts := PostListOptions{
|
||||
Status: PostStatus(c.Query("status")),
|
||||
Limit: queryInt(c, "limit"),
|
||||
Offset: queryInt(c, "offset"),
|
||||
}
|
||||
posts, err := s.store.ListPosts(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
total, err := s.store.CountPosts(c.Request.Context(), opts.Status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts, "total": total})
|
||||
}
|
||||
|
||||
func queryInt(c *gin.Context, key string) int {
|
||||
value := c.Query(key)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (s *Server) login(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var input LoginInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.store.Login(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
SetSessionCookie(c, result.Token, result.ExpiresAt)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": result.User,
|
||||
"expiresAt": result.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) me(c *gin.Context) {
|
||||
user, ok := c.Request.Context().Value(userContextKey).(User)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||
}
|
||||
|
||||
func (s *Server) logout(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
token, _ := c.Cookie(SessionCookieName)
|
||||
if err := s.store.Logout(c.Request.Context(), token); err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
ClearSessionCookie(c)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) getPost(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
post, err := s.store.GetPost(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
func (s *Server) createPost(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var input PostInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
post, err := s.store.CreatePost(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"post": post})
|
||||
}
|
||||
|
||||
func (s *Server) updatePost(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var input PostInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
post, err := s.store.UpdatePost(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
func (s *Server) deletePost(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
job, err := s.store.DeletePost(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
s.enqueueBuildJob(job)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "buildJob": job})
|
||||
}
|
||||
|
||||
func (s *Server) uploadAsset(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
if s.uploader == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "asset uploader is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
asset, err := s.uploader.Upload(c.Request.Context(), file, header)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"asset": asset})
|
||||
}
|
||||
|
||||
type GenerateSlugInput struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
PostID string `json:"postId"`
|
||||
}
|
||||
|
||||
func (s *Server) generateSlug(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var input GenerateSlugInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(input.Title) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "title is required"})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 35*time.Second)
|
||||
defer cancel()
|
||||
|
||||
base, err := s.generateSlugBase(ctx, input.Title, input.Summary)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
slug, err := s.store.UniqueSlug(c.Request.Context(), base, input.PostID)
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"slug": slug})
|
||||
}
|
||||
|
||||
func (s *Server) generateSlugBase(ctx context.Context, title string, summary string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s.slugProvider)) {
|
||||
case "", "deepseek":
|
||||
apiKey := strings.TrimSpace(s.deepSeek.APIKey)
|
||||
if apiKey == "" {
|
||||
return "", errors.New("DEEPSEEK_API_KEY is not configured")
|
||||
}
|
||||
return ai.GenerateSlug(ctx, ai.Config{
|
||||
APIKey: apiKey,
|
||||
BaseURL: s.deepSeek.BaseURL,
|
||||
Model: s.deepSeek.Model,
|
||||
}, title, summary)
|
||||
case "local", "local_llm", "ollama":
|
||||
return ai.GenerateLocalSlug(ctx, ai.LocalConfig{
|
||||
URL: s.localLLM.URL,
|
||||
Model: s.localLLM.Model,
|
||||
Temperature: s.localLLM.Temperature,
|
||||
TopP: s.localLLM.TopP,
|
||||
NumPredict: s.localLLM.NumPredict,
|
||||
}, title, summary)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported slug provider %q", s.slugProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) buildPost(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
job, err := s.store.CreateManualBuildJob(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.enqueueBuildJob(&job)
|
||||
c.JSON(http.StatusAccepted, gin.H{"buildJob": job})
|
||||
}
|
||||
|
||||
func (s *Server) publishPost(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
post, job, err := s.store.PublishPost(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.enqueueBuildJob(&job)
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"post": post,
|
||||
"buildJob": job,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) enqueueBuildJob(job *BuildJob) {
|
||||
if job == nil || s.builder == nil {
|
||||
return
|
||||
}
|
||||
if !s.builder.Enqueue(job.ID) {
|
||||
_ = s.store.MarkBuildJobFailed(context.Background(), job.ID, "", "build queue is full")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getBuildJob(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
job, err := s.store.GetBuildJob(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"buildJob": job})
|
||||
}
|
||||
|
||||
func (s *Server) requireStore(c *gin.Context) bool {
|
||||
if s.store != nil {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database is not configured"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token, err := c.Cookie(SessionCookieName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.UserBySessionToken(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(c.Request.Context(), userContextKey, user)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func writeStoreError(c *gin.Context, err error) {
|
||||
switch err {
|
||||
case ErrNotFound:
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case ErrInvalidCredentials:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
31
backend/internal/admin/router_test.go
Normal file
31
backend/internal/admin/router_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
server := NewServer(nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
server.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyWithoutDatabase(t *testing.T) {
|
||||
server := NewServer(nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
server.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code)
|
||||
}
|
||||
}
|
||||
51
backend/internal/admin/static.go
Normal file
51
backend/internal/admin/static.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var adminWeb embed.FS
|
||||
|
||||
func adminIndex(adminDir string) ([]byte, error) {
|
||||
if strings.TrimSpace(adminDir) != "" {
|
||||
path := filepath.Join(adminDir, "index.html")
|
||||
if page, err := os.ReadFile(path); err == nil {
|
||||
return rewriteAdminBase(page), nil
|
||||
}
|
||||
}
|
||||
return adminWeb.ReadFile("web/index.html")
|
||||
}
|
||||
|
||||
func serveAdminFile(c *gin.Context, adminDir string) bool {
|
||||
if strings.TrimSpace(adminDir) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
requested := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
if requested == "" {
|
||||
return false
|
||||
}
|
||||
cleaned := filepath.Clean(requested)
|
||||
if cleaned == "." || strings.HasPrefix(cleaned, "..") {
|
||||
return false
|
||||
}
|
||||
|
||||
path := filepath.Join(adminDir, cleaned)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
http.ServeFile(c.Writer, c.Request, path)
|
||||
return true
|
||||
}
|
||||
|
||||
func rewriteAdminBase(page []byte) []byte {
|
||||
return []byte(strings.Replace(string(page), `<base href="/">`, `<base href="/admin/">`, 1))
|
||||
}
|
||||
72
backend/internal/admin/status.go
Normal file
72
backend/internal/admin/status.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package admin
|
||||
|
||||
import "fmt"
|
||||
|
||||
func ValidPostStatus(status PostStatus) bool {
|
||||
switch status {
|
||||
case PostStatusDraft, PostStatusPublished, PostStatusArchived, PostStatusDeleted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsPublicPostStatus(status PostStatus) bool {
|
||||
return status == PostStatusPublished || status == PostStatusArchived
|
||||
}
|
||||
|
||||
func ValidVersionReason(reason VersionReason) bool {
|
||||
switch reason {
|
||||
case VersionReasonSave, VersionReasonPublish, VersionReasonUnpublish, VersionReasonArchive, VersionReasonRestore, VersionReasonImport, VersionReasonRollback:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidBuildJobStatus(status BuildJobStatus) bool {
|
||||
switch status {
|
||||
case BuildJobStatusQueued, BuildJobStatusRunning, BuildJobStatusSuccess, BuildJobStatusFailed, BuildJobStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidBuildJobTrigger(trigger BuildJobTrigger) bool {
|
||||
switch trigger {
|
||||
case BuildJobTriggerPublish, BuildJobTriggerManual, BuildJobTriggerImport, BuildJobTriggerSync:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func CanTransitionPostStatus(from PostStatus, to PostStatus) bool {
|
||||
if !ValidPostStatus(from) || !ValidPostStatus(to) {
|
||||
return false
|
||||
}
|
||||
if from == to {
|
||||
return true
|
||||
}
|
||||
|
||||
switch from {
|
||||
case PostStatusDraft:
|
||||
return to == PostStatusPublished || to == PostStatusArchived || to == PostStatusDeleted
|
||||
case PostStatusPublished:
|
||||
return to == PostStatusDraft || to == PostStatusArchived || to == PostStatusDeleted
|
||||
case PostStatusArchived:
|
||||
return to == PostStatusDraft || to == PostStatusPublished || to == PostStatusDeleted
|
||||
case PostStatusDeleted:
|
||||
return to == PostStatusDraft
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidatePostStatusTransition(from PostStatus, to PostStatus) error {
|
||||
if CanTransitionPostStatus(from, to) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid post status transition: %s -> %s", from, to)
|
||||
}
|
||||
38
backend/internal/admin/status_test.go
Normal file
38
backend/internal/admin/status_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package admin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCanTransitionPostStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
from PostStatus
|
||||
to PostStatus
|
||||
want bool
|
||||
}{
|
||||
{name: "draft to published", from: PostStatusDraft, to: PostStatusPublished, want: true},
|
||||
{name: "published to draft", from: PostStatusPublished, to: PostStatusDraft, want: true},
|
||||
{name: "published to archived", from: PostStatusPublished, to: PostStatusArchived, want: true},
|
||||
{name: "archived to draft", from: PostStatusArchived, to: PostStatusDraft, want: true},
|
||||
{name: "deleted to draft", from: PostStatusDeleted, to: PostStatusDraft, want: true},
|
||||
{name: "deleted to published blocked", from: PostStatusDeleted, to: PostStatusPublished, want: false},
|
||||
{name: "unknown blocked", from: PostStatus("unknown"), to: PostStatusDraft, want: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := CanTransitionPostStatus(test.from, test.to)
|
||||
if got != test.want {
|
||||
t.Fatalf("CanTransitionPostStatus(%q, %q) = %v, want %v", test.from, test.to, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePostStatusTransition(t *testing.T) {
|
||||
if err := ValidatePostStatusTransition(PostStatusDraft, PostStatusPublished); err != nil {
|
||||
t.Fatalf("valid transition returned error: %v", err)
|
||||
}
|
||||
if err := ValidatePostStatusTransition(PostStatusDeleted, PostStatusPublished); err == nil {
|
||||
t.Fatal("invalid transition returned nil error")
|
||||
}
|
||||
}
|
||||
823
backend/internal/admin/store.go
Normal file
823
backend/internal/admin/store.go
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
type PostListOptions struct {
|
||||
Status PostStatus
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type PostInput struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
BodyMarkdown string `json:"bodyMarkdown"`
|
||||
Status PostStatus `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Cover string `json:"cover"`
|
||||
SlugSource string `json:"slugSource"`
|
||||
SlugLocked bool `json:"slugLocked"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) ListPosts(ctx context.Context, opts PostListOptions) ([]Post, error) {
|
||||
limit := opts.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
offset := opts.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
args := []any{limit, offset}
|
||||
where := "deleted_at IS NULL"
|
||||
if opts.Status != "" {
|
||||
if !ValidPostStatus(opts.Status) {
|
||||
return nil, fmt.Errorf("invalid post status: %s", opts.Status)
|
||||
}
|
||||
args = append(args, opts.Status)
|
||||
where += fmt.Sprintf(" AND status = $%d", len(args))
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
||||
FROM posts
|
||||
WHERE `+where+`
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $1 OFFSET $2`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list posts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []Post
|
||||
for rows.Next() {
|
||||
post, err := scanPost(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts = append(posts, post)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list posts rows: %w", err)
|
||||
}
|
||||
if err := s.attachTags(ctx, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (s *Store) CountPosts(ctx context.Context, status PostStatus) (int, error) {
|
||||
args := []any{}
|
||||
where := "deleted_at IS NULL"
|
||||
if status != "" {
|
||||
if !ValidPostStatus(status) {
|
||||
return 0, fmt.Errorf("invalid post status: %s", status)
|
||||
}
|
||||
args = append(args, status)
|
||||
where += " AND status = $1"
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("count posts: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetPost(ctx context.Context, id string) (Post, error) {
|
||||
post, err := scanPost(s.db.QueryRow(ctx, `
|
||||
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
||||
FROM posts
|
||||
WHERE id = $1 AND deleted_at IS NULL`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Post{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("get post: %w", err)
|
||||
}
|
||||
posts := []Post{post}
|
||||
if err := s.attachTags(ctx, posts); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
post = posts[0]
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *Store) UniqueSlug(ctx context.Context, base string, excludePostID string) (string, error) {
|
||||
base = sanitizeSlug(base)
|
||||
if base == "" {
|
||||
return "", errors.New("slug is empty")
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
candidate := base
|
||||
if i > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d", base, i+1)
|
||||
}
|
||||
exists, err := s.slugExists(ctx, candidate, excludePostID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available slug for %q", base)
|
||||
}
|
||||
|
||||
func (s *Store) slugExists(ctx context.Context, slug string, excludePostID string) (bool, error) {
|
||||
var exists bool
|
||||
var err error
|
||||
if strings.TrimSpace(excludePostID) != "" {
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE slug = $1 AND id::text <> $2
|
||||
)`, slug, excludePostID).Scan(&exists)
|
||||
} else {
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE slug = $1
|
||||
)`, slug).Scan(&exists)
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check slug exists: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreatePost(ctx context.Context, input PostInput) (Post, error) {
|
||||
if err := validatePostInput(input, true); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("begin create post: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
post, err := insertPost(ctx, tx, input)
|
||||
if err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
if err := replacePostTags(ctx, tx, post.ID, input.Tags); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
post.Tags = normalizeTagNames(input.Tags)
|
||||
if err := insertPostVersion(ctx, tx, post, VersionReasonSave); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Post{}, fmt.Errorf("commit create post: %w", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePost(ctx context.Context, id string, input PostInput) (Post, error) {
|
||||
if err := validatePostInput(input, false); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("begin update post: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var currentStatus PostStatus
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT status
|
||||
FROM posts
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
FOR UPDATE`, id).Scan(¤tStatus)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Post{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("lock post: %w", err)
|
||||
}
|
||||
if err := ValidatePostStatusTransition(currentStatus, input.Status); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
post, err := updatePost(ctx, tx, id, input)
|
||||
if err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
if err := replacePostTags(ctx, tx, post.ID, input.Tags); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
post.Tags = normalizeTagNames(input.Tags)
|
||||
if err := insertPostVersion(ctx, tx, post, VersionReasonSave); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Post{}, fmt.Errorf("commit update post: %w", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateManualBuildJob(ctx context.Context, postID string) (BuildJob, error) {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return BuildJob{}, fmt.Errorf("begin create build job: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var exists bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
)`, postID).Scan(&exists); err != nil {
|
||||
return BuildJob{}, fmt.Errorf("check post for build: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return BuildJob{}, ErrNotFound
|
||||
}
|
||||
|
||||
job, err := insertBuildJob(ctx, tx, BuildJobTriggerManual, &postID)
|
||||
if err != nil {
|
||||
return BuildJob{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return BuildJob{}, fmt.Errorf("commit create build job: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeletePost(ctx context.Context, id string) (*BuildJob, error) {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin delete post: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var currentStatus PostStatus
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT status
|
||||
FROM posts
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
FOR UPDATE`, id).Scan(¤tStatus)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock post: %w", err)
|
||||
}
|
||||
|
||||
commandTag, err := tx.Exec(ctx, `
|
||||
UPDATE posts
|
||||
SET status = 'deleted', deleted_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete post: %w", err)
|
||||
}
|
||||
if commandTag.RowsAffected() == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
var job *BuildJob
|
||||
if IsPublicPostStatus(currentStatus) {
|
||||
created, err := insertBuildJob(ctx, tx, BuildJobTriggerManual, &id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
job = &created
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit delete post: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) PublishPost(ctx context.Context, id string) (Post, BuildJob, error) {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Post{}, BuildJob{}, fmt.Errorf("begin publish post: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var current Post
|
||||
current, err = scanPost(tx.QueryRow(ctx, `
|
||||
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
||||
FROM posts
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
FOR UPDATE`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Post{}, BuildJob{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Post{}, BuildJob{}, fmt.Errorf("lock post: %w", err)
|
||||
}
|
||||
if err := ValidatePostStatusTransition(current.Status, PostStatusPublished); err != nil {
|
||||
return Post{}, BuildJob{}, err
|
||||
}
|
||||
|
||||
post, err := scanPost(tx.QueryRow(ctx, `
|
||||
UPDATE posts
|
||||
SET status = 'published',
|
||||
version = version + 1,
|
||||
published_at = COALESCE(published_at, now()),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`, id))
|
||||
if err != nil {
|
||||
return Post{}, BuildJob{}, fmt.Errorf("publish post: %w", err)
|
||||
}
|
||||
if err := insertPostVersion(ctx, tx, post, VersionReasonPublish); err != nil {
|
||||
return Post{}, BuildJob{}, err
|
||||
}
|
||||
|
||||
job, err := insertBuildJob(ctx, tx, BuildJobTriggerPublish, &post.ID)
|
||||
if err != nil {
|
||||
return Post{}, BuildJob{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Post{}, BuildJob{}, fmt.Errorf("commit publish post: %w", err)
|
||||
}
|
||||
posts := []Post{post}
|
||||
if err := s.attachTags(ctx, posts); err != nil {
|
||||
return Post{}, BuildJob{}, err
|
||||
}
|
||||
post = posts[0]
|
||||
return post, job, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetBuildJob(ctx context.Context, id string) (BuildJob, error) {
|
||||
job, err := scanBuildJob(s.db.QueryRow(ctx, `
|
||||
SELECT id, trigger, status, post_id, started_at, finished_at, log, error, created_at, created_by
|
||||
FROM build_jobs
|
||||
WHERE id = $1`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return BuildJob{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return BuildJob{}, fmt.Errorf("get build job: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAsset(ctx context.Context, asset Asset) (Asset, error) {
|
||||
created, err := scanAsset(s.db.QueryRow(ctx, `
|
||||
INSERT INTO assets (path, original_name, mime_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (path)
|
||||
DO UPDATE SET original_name = excluded.original_name,
|
||||
mime_type = excluded.mime_type,
|
||||
size_bytes = excluded.size_bytes,
|
||||
sha256 = excluded.sha256
|
||||
RETURNING id, path, original_name, mime_type, size_bytes, sha256, created_at, created_by`,
|
||||
asset.Path,
|
||||
asset.OriginalName,
|
||||
asset.MimeType,
|
||||
asset.SizeBytes,
|
||||
asset.SHA256,
|
||||
))
|
||||
if err != nil {
|
||||
return Asset{}, fmt.Errorf("create asset: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *Store) PublishedPostsForExport(ctx context.Context) ([]Post, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
||||
FROM posts
|
||||
WHERE status IN ('published', 'archived') AND deleted_at IS NULL
|
||||
ORDER BY published_at DESC NULLS LAST, updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("published posts for export: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []Post
|
||||
for rows.Next() {
|
||||
post, err := scanPost(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts = append(posts, post)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("published posts rows: %w", err)
|
||||
}
|
||||
if err := s.attachTags(ctx, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkBuildJobRunning(ctx context.Context, id string) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE build_jobs
|
||||
SET status = 'running', started_at = now()
|
||||
WHERE id = $1 AND status = 'queued'`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark build job running: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkBuildJobSuccess(ctx context.Context, id string, log string) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE build_jobs
|
||||
SET status = 'success', finished_at = now(), log = $2, error = ''
|
||||
WHERE id = $1`, id, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark build job success: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkBuildJobFailed(ctx context.Context, id string, log string, message string) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE build_jobs
|
||||
SET status = 'failed', finished_at = now(), log = $2, error = $3
|
||||
WHERE id = $1`, id, log, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark build job failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePostInput(input PostInput, creating bool) error {
|
||||
if strings.TrimSpace(input.Title) == "" {
|
||||
return errors.New("title is required")
|
||||
}
|
||||
if strings.TrimSpace(input.Slug) == "" {
|
||||
return errors.New("slug is required")
|
||||
}
|
||||
if input.Status == "" {
|
||||
if creating {
|
||||
input.Status = PostStatusDraft
|
||||
} else {
|
||||
return errors.New("status is required")
|
||||
}
|
||||
}
|
||||
if !ValidPostStatus(input.Status) {
|
||||
return fmt.Errorf("invalid post status: %s", input.Status)
|
||||
}
|
||||
if creating && input.Status == PostStatusDeleted {
|
||||
return errors.New("new post cannot be deleted")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) attachTags(ctx context.Context, posts []Post) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(posts))
|
||||
index := make(map[string]int, len(posts))
|
||||
for i := range posts {
|
||||
ids = append(ids, posts[i].ID)
|
||||
index[posts[i].ID] = i
|
||||
posts[i].Tags = []string{}
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT pt.post_id::text, t.name
|
||||
FROM post_tags pt
|
||||
JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.post_id::text = ANY($1)
|
||||
ORDER BY t.name`, ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load post tags: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var postID string
|
||||
var tag string
|
||||
if err := rows.Scan(&postID, &tag); err != nil {
|
||||
return fmt.Errorf("scan post tag: %w", err)
|
||||
}
|
||||
if i, ok := index[postID]; ok {
|
||||
posts[i].Tags = append(posts[i].Tags, tag)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("post tags rows: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertPost(ctx context.Context, tx pgx.Tx, input PostInput) (Post, error) {
|
||||
if input.Status == "" {
|
||||
input.Status = PostStatusDraft
|
||||
}
|
||||
if input.SlugSource == "" {
|
||||
input.SlugSource = "manual"
|
||||
}
|
||||
|
||||
post, err := scanPost(tx.QueryRow(ctx, `
|
||||
INSERT INTO posts (slug, title, summary, body_markdown, status, cover, slug_source, slug_locked, published_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CASE WHEN $5 IN ('published', 'archived') THEN now() ELSE NULL END, COALESCE($9, now()))
|
||||
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
|
||||
input.Slug,
|
||||
input.Title,
|
||||
input.Summary,
|
||||
input.BodyMarkdown,
|
||||
input.Status,
|
||||
input.Cover,
|
||||
input.SlugSource,
|
||||
input.SlugLocked,
|
||||
input.CreatedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return Post{}, normalizeStoreError("insert post", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func updatePost(ctx context.Context, tx pgx.Tx, id string, input PostInput) (Post, error) {
|
||||
if input.SlugSource == "" {
|
||||
input.SlugSource = "manual"
|
||||
}
|
||||
|
||||
post, err := scanPost(tx.QueryRow(ctx, `
|
||||
UPDATE posts
|
||||
SET slug = $2,
|
||||
title = $3,
|
||||
summary = $4,
|
||||
body_markdown = $5,
|
||||
status = $6,
|
||||
cover = $7,
|
||||
slug_source = $8,
|
||||
slug_locked = $9,
|
||||
version = version + 1,
|
||||
published_at = CASE WHEN $6 IN ('published', 'archived') AND published_at IS NULL THEN now() ELSE published_at END,
|
||||
created_at = COALESCE($10, created_at),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
|
||||
id,
|
||||
input.Slug,
|
||||
input.Title,
|
||||
input.Summary,
|
||||
input.BodyMarkdown,
|
||||
input.Status,
|
||||
input.Cover,
|
||||
input.SlugSource,
|
||||
input.SlugLocked,
|
||||
input.CreatedAt,
|
||||
))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Post{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Post{}, normalizeStoreError("update post", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func insertPostVersion(ctx context.Context, tx pgx.Tx, post Post, reason VersionReason) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO post_versions (post_id, version, title, summary, body_markdown, status, reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
post.ID,
|
||||
post.Version,
|
||||
post.Title,
|
||||
post.Summary,
|
||||
post.BodyMarkdown,
|
||||
post.Status,
|
||||
reason,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert post version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replacePostTags(ctx context.Context, tx pgx.Tx, postID string, tags []string) error {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID); err != nil {
|
||||
return fmt.Errorf("clear post tags: %w", err)
|
||||
}
|
||||
|
||||
for _, name := range normalizeTagNames(tags) {
|
||||
slug := tagSlug(name)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var tagID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO tags (name, slug)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (slug)
|
||||
DO UPDATE SET name = excluded.name, updated_at = now()
|
||||
RETURNING id`, name, slug).Scan(&tagID); err != nil {
|
||||
return fmt.Errorf("upsert tag %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO post_tags (post_id, tag_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`, postID, tagID); err != nil {
|
||||
return fmt.Errorf("link post tag %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeTagNames(tags []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var normalized []string
|
||||
for _, tag := range tags {
|
||||
name := strings.TrimSpace(tag)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
normalized = append(normalized, name)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func tagSlug(tag string) string {
|
||||
slug := strings.ToLower(strings.TrimSpace(tag))
|
||||
slug = strings.ReplaceAll(slug, "_", "-")
|
||||
slug = strings.Join(strings.Fields(slug), "-")
|
||||
return strings.Trim(slug, "-")
|
||||
}
|
||||
|
||||
func sanitizeSlug(slug string) string {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
replacer := strings.NewReplacer("_", "-", " ", "-")
|
||||
slug = replacer.Replace(slug)
|
||||
var builder strings.Builder
|
||||
lastHyphen := false
|
||||
for _, r := range slug {
|
||||
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if valid {
|
||||
builder.WriteRune(r)
|
||||
lastHyphen = false
|
||||
continue
|
||||
}
|
||||
if r == '-' && !lastHyphen {
|
||||
builder.WriteRune('-')
|
||||
lastHyphen = true
|
||||
}
|
||||
}
|
||||
cleaned := strings.Trim(builder.String(), "-")
|
||||
if len(cleaned) > 80 {
|
||||
cleaned = strings.Trim(cleaned[:80], "-")
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func insertBuildJob(ctx context.Context, tx pgx.Tx, trigger BuildJobTrigger, postID *string) (BuildJob, error) {
|
||||
job, err := scanBuildJob(tx.QueryRow(ctx, `
|
||||
INSERT INTO build_jobs (trigger, status, post_id)
|
||||
VALUES ($1, 'queued', $2)
|
||||
RETURNING id, trigger, status, post_id, started_at, finished_at, log, error, created_at, created_by`,
|
||||
trigger,
|
||||
postID,
|
||||
))
|
||||
if err != nil {
|
||||
return BuildJob{}, fmt.Errorf("insert build job: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
type postScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanPost(row postScanner) (Post, error) {
|
||||
var post Post
|
||||
var publishedAt sql.NullTime
|
||||
var deletedAt sql.NullTime
|
||||
err := row.Scan(
|
||||
&post.ID,
|
||||
&post.Slug,
|
||||
&post.Title,
|
||||
&post.Summary,
|
||||
&post.BodyMarkdown,
|
||||
&post.Status,
|
||||
&post.Cover,
|
||||
&post.Version,
|
||||
&post.SlugSource,
|
||||
&post.SlugLocked,
|
||||
&publishedAt,
|
||||
&post.CreatedAt,
|
||||
&post.UpdatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
post.PublishedAt = nullTimePtr(publishedAt)
|
||||
post.DeletedAt = nullTimePtr(deletedAt)
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func scanBuildJob(row postScanner) (BuildJob, error) {
|
||||
var job BuildJob
|
||||
var postID sql.NullString
|
||||
var startedAt sql.NullTime
|
||||
var finishedAt sql.NullTime
|
||||
var createdBy sql.NullString
|
||||
err := row.Scan(
|
||||
&job.ID,
|
||||
&job.Trigger,
|
||||
&job.Status,
|
||||
&postID,
|
||||
&startedAt,
|
||||
&finishedAt,
|
||||
&job.Log,
|
||||
&job.Error,
|
||||
&job.CreatedAt,
|
||||
&createdBy,
|
||||
)
|
||||
if err != nil {
|
||||
return BuildJob{}, err
|
||||
}
|
||||
job.PostID = nullStringPtr(postID)
|
||||
job.StartedAt = nullTimePtr(startedAt)
|
||||
job.FinishedAt = nullTimePtr(finishedAt)
|
||||
job.CreatedBy = nullStringPtr(createdBy)
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func scanAsset(row postScanner) (Asset, error) {
|
||||
var asset Asset
|
||||
var createdBy sql.NullString
|
||||
err := row.Scan(
|
||||
&asset.ID,
|
||||
&asset.Path,
|
||||
&asset.OriginalName,
|
||||
&asset.MimeType,
|
||||
&asset.SizeBytes,
|
||||
&asset.SHA256,
|
||||
&asset.CreatedAt,
|
||||
&createdBy,
|
||||
)
|
||||
if err != nil {
|
||||
return Asset{}, err
|
||||
}
|
||||
asset.CreatedBy = nullStringPtr(createdBy)
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func nullTimePtr(value sql.NullTime) *time.Time {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
return &value.Time
|
||||
}
|
||||
|
||||
func nullStringPtr(value sql.NullString) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
return &value.String
|
||||
}
|
||||
|
||||
func normalizeStoreError(action string, err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return fmt.Errorf("%s: slug already exists", action)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", action, err)
|
||||
}
|
||||
101
backend/internal/admin/types.go
Normal file
101
backend/internal/admin/types.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package admin
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt"`
|
||||
}
|
||||
|
||||
type PostStatus string
|
||||
|
||||
const (
|
||||
PostStatusDraft PostStatus = "draft"
|
||||
PostStatusPublished PostStatus = "published"
|
||||
PostStatusArchived PostStatus = "archived"
|
||||
PostStatusDeleted PostStatus = "deleted"
|
||||
)
|
||||
|
||||
type VersionReason string
|
||||
|
||||
const (
|
||||
VersionReasonSave VersionReason = "save"
|
||||
VersionReasonPublish VersionReason = "publish"
|
||||
VersionReasonUnpublish VersionReason = "unpublish"
|
||||
VersionReasonArchive VersionReason = "archive"
|
||||
VersionReasonRestore VersionReason = "restore"
|
||||
VersionReasonImport VersionReason = "import"
|
||||
VersionReasonRollback VersionReason = "rollback"
|
||||
)
|
||||
|
||||
type BuildJobTrigger string
|
||||
|
||||
const (
|
||||
BuildJobTriggerPublish BuildJobTrigger = "publish"
|
||||
BuildJobTriggerManual BuildJobTrigger = "manual"
|
||||
BuildJobTriggerImport BuildJobTrigger = "import"
|
||||
BuildJobTriggerSync BuildJobTrigger = "sync"
|
||||
)
|
||||
|
||||
type BuildJobStatus string
|
||||
|
||||
const (
|
||||
BuildJobStatusQueued BuildJobStatus = "queued"
|
||||
BuildJobStatusRunning BuildJobStatus = "running"
|
||||
BuildJobStatusSuccess BuildJobStatus = "success"
|
||||
BuildJobStatusFailed BuildJobStatus = "failed"
|
||||
BuildJobStatusCancelled BuildJobStatus = "cancelled"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
BodyMarkdown string `json:"bodyMarkdown"`
|
||||
Status PostStatus `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Cover string `json:"cover"`
|
||||
Version int `json:"version"`
|
||||
SlugSource string `json:"slugSource"`
|
||||
SlugLocked bool `json:"slugLocked"`
|
||||
PublishedAt *time.Time `json:"publishedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
OriginalName string `json:"originalName"`
|
||||
MimeType string `json:"mimeType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy *string `json:"createdBy"`
|
||||
}
|
||||
|
||||
type BuildJob struct {
|
||||
ID string `json:"id"`
|
||||
Trigger BuildJobTrigger `json:"trigger"`
|
||||
Status BuildJobStatus `json:"status"`
|
||||
PostID *string `json:"postId"`
|
||||
StartedAt *time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt"`
|
||||
Log string `json:"log"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy *string `json:"createdBy"`
|
||||
}
|
||||
260
backend/internal/admin/web/assets/admin.css
Normal file
260
backend/internal/admin/web/assets/admin.css
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
:root {
|
||||
color: #232428;
|
||||
background: #f7f6f2;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC",
|
||||
"Source Han Sans SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f7f6f2;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 0.65em;
|
||||
padding: 0.72em 1.15em;
|
||||
background: #243b53;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1c3147;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: #fff;
|
||||
color: #243b53;
|
||||
box-shadow: 0 0 0.2em rgb(29 53 87 / 13%);
|
||||
}
|
||||
|
||||
button.publish {
|
||||
background: #7b4f27;
|
||||
}
|
||||
|
||||
button.publish:hover {
|
||||
background: #643f1f;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e1ded7;
|
||||
border-radius: 0.7em;
|
||||
background: #fff;
|
||||
color: #232428;
|
||||
padding: 0.8em 0.95em;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: #9aa9b6;
|
||||
box-shadow: 0 0 0 0.2em rgb(36 59 83 / 10%);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.5em;
|
||||
color: #55575d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(92vw, 1180px);
|
||||
margin: 0 auto;
|
||||
padding: 5vh 0;
|
||||
}
|
||||
|
||||
.login-view {
|
||||
min-height: 90vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(92vw, 28em);
|
||||
display: grid;
|
||||
gap: 1.2em;
|
||||
padding: 2em;
|
||||
border-radius: 1em;
|
||||
background: #fff;
|
||||
box-shadow: 0 1em 3em rgb(29 53 87 / 10%);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2em;
|
||||
margin-bottom: 1.8em;
|
||||
}
|
||||
|
||||
.topbar h1,
|
||||
.editor-head h2,
|
||||
.panel-heading h2,
|
||||
.login-panel h1 {
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35em;
|
||||
color: #8b8175;
|
||||
font-size: 0.78em;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topbar-actions,
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7em;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
color: #6d7179;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(18em, 0.9fr) minmax(0, 2.1fr);
|
||||
gap: 1.4em;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.post-list-panel,
|
||||
.editor-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.panel-heading select {
|
||||
max-width: 9em;
|
||||
}
|
||||
|
||||
.post-list {
|
||||
display: grid;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 0.45em;
|
||||
text-align: left;
|
||||
border-radius: 0.8em;
|
||||
padding: 1em;
|
||||
background: #fff;
|
||||
color: #232428;
|
||||
box-shadow: 0 0 0.2em rgb(29 53 87 / 10%);
|
||||
}
|
||||
|
||||
.post-item:hover,
|
||||
.post-item.active {
|
||||
background: #fbfaf7;
|
||||
box-shadow: 0 0.45em 1.4em rgb(29 53 87 / 11%);
|
||||
}
|
||||
|
||||
.post-item-title {
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.post-item-meta {
|
||||
color: #777b82;
|
||||
font-size: 0.82em;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
border-radius: 1em;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0.2em rgb(29 53 87 / 10%);
|
||||
}
|
||||
|
||||
.editor-form {
|
||||
display: grid;
|
||||
gap: 1.2em;
|
||||
padding: 1.4em;
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.fields-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.wide-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.body-field textarea {
|
||||
min-height: 42vh;
|
||||
font-family:
|
||||
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 1.4em;
|
||||
margin: 0;
|
||||
color: #7b4f27;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.topbar,
|
||||
.editor-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace,
|
||||
.fields-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar-actions,
|
||||
.editor-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
321
backend/internal/admin/web/assets/admin.js
Normal file
321
backend/internal/admin/web/assets/admin.js
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
const state = {
|
||||
user: null,
|
||||
posts: [],
|
||||
currentPost: null,
|
||||
};
|
||||
|
||||
const el = {
|
||||
loginView: document.querySelector("#loginView"),
|
||||
appView: document.querySelector("#appView"),
|
||||
loginForm: document.querySelector("#loginForm"),
|
||||
loginUsername: document.querySelector("#loginUsername"),
|
||||
loginPassword: document.querySelector("#loginPassword"),
|
||||
loginMessage: document.querySelector("#loginMessage"),
|
||||
userBadge: document.querySelector("#userBadge"),
|
||||
logoutButton: document.querySelector("#logoutButton"),
|
||||
newPostButton: document.querySelector("#newPostButton"),
|
||||
statusFilter: document.querySelector("#statusFilter"),
|
||||
postList: document.querySelector("#postList"),
|
||||
postForm: document.querySelector("#postForm"),
|
||||
editorMode: document.querySelector("#editorMode"),
|
||||
editorTitle: document.querySelector("#editorTitle"),
|
||||
titleInput: document.querySelector("#titleInput"),
|
||||
slugInput: document.querySelector("#slugInput"),
|
||||
statusInput: document.querySelector("#statusInput"),
|
||||
coverInput: document.querySelector("#coverInput"),
|
||||
tagsInput: document.querySelector("#tagsInput"),
|
||||
summaryInput: document.querySelector("#summaryInput"),
|
||||
bodyInput: document.querySelector("#bodyInput"),
|
||||
publishButton: document.querySelector("#publishButton"),
|
||||
editorMessage: document.querySelector("#editorMessage"),
|
||||
};
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(`/api/admin${path}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
el.loginView.hidden = false;
|
||||
el.appView.hidden = true;
|
||||
el.loginPassword.focus();
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
el.loginView.hidden = true;
|
||||
el.appView.hidden = false;
|
||||
el.userBadge.textContent = state.user?.username ?? "";
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
try {
|
||||
const data = await api("/me");
|
||||
state.user = data.user;
|
||||
showApp();
|
||||
await loadPosts();
|
||||
resetEditor();
|
||||
} catch {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPosts() {
|
||||
const status = el.statusFilter.value;
|
||||
const query = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
const data = await api(`/posts${query}`);
|
||||
state.posts = data.posts ?? [];
|
||||
renderPostList();
|
||||
}
|
||||
|
||||
function renderPostList() {
|
||||
el.postList.innerHTML = "";
|
||||
|
||||
if (state.posts.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "message";
|
||||
empty.textContent = "暂无文章";
|
||||
el.postList.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const post of state.posts) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "post-item";
|
||||
if (state.currentPost?.id === post.id) {
|
||||
button.classList.add("active");
|
||||
}
|
||||
button.innerHTML = `
|
||||
<span class="post-item-title"></span>
|
||||
<span class="post-item-meta"></span>
|
||||
`;
|
||||
button.querySelector(".post-item-title").textContent = post.title || "未命名";
|
||||
button.querySelector(".post-item-meta").textContent = `${statusText(post.status)} / ${formatDate(post.updatedAt)}`;
|
||||
button.addEventListener("click", () => selectPost(post.id));
|
||||
el.postList.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectPost(id) {
|
||||
const data = await api(`/posts/${id}`);
|
||||
state.currentPost = data.post;
|
||||
fillEditor(data.post);
|
||||
renderPostList();
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
state.currentPost = null;
|
||||
el.editorMode.textContent = "新文章";
|
||||
el.editorTitle.textContent = "开始写作";
|
||||
el.postForm.reset();
|
||||
el.statusInput.value = "draft";
|
||||
el.editorMessage.textContent = "";
|
||||
renderPostList();
|
||||
}
|
||||
|
||||
function fillEditor(post) {
|
||||
el.editorMode.textContent = `版本 ${post.version}`;
|
||||
el.editorTitle.textContent = post.title || "未命名";
|
||||
el.titleInput.value = post.title ?? "";
|
||||
el.slugInput.value = post.slug ?? "";
|
||||
el.statusInput.value = post.status ?? "draft";
|
||||
el.coverInput.value = post.cover ?? "";
|
||||
el.tagsInput.value = (post.tags ?? []).join(", ");
|
||||
el.summaryInput.value = post.summary ?? "";
|
||||
el.bodyInput.value = post.bodyMarkdown ?? "";
|
||||
el.editorMessage.textContent = "";
|
||||
}
|
||||
|
||||
function readPostInput() {
|
||||
return {
|
||||
title: el.titleInput.value.trim(),
|
||||
slug: el.slugInput.value.trim(),
|
||||
status: el.statusInput.value,
|
||||
cover: el.coverInput.value.trim(),
|
||||
tags: parseTags(el.tagsInput.value),
|
||||
summary: el.summaryInput.value.trim(),
|
||||
bodyMarkdown: el.bodyInput.value,
|
||||
slugSource: "manual",
|
||||
slugLocked: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function savePost() {
|
||||
const input = readPostInput();
|
||||
const path = state.currentPost ? `/posts/${state.currentPost.id}` : "/posts";
|
||||
const method = state.currentPost ? "PUT" : "POST";
|
||||
const data = await api(path, {
|
||||
method,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
state.currentPost = data.post;
|
||||
fillEditor(data.post);
|
||||
await loadPosts();
|
||||
el.editorMessage.textContent = "已保存";
|
||||
}
|
||||
|
||||
async function publishPost() {
|
||||
if (!state.currentPost) {
|
||||
await savePost();
|
||||
}
|
||||
|
||||
const data = await api(`/posts/${state.currentPost.id}/publish`, { method: "POST" });
|
||||
state.currentPost = data.post;
|
||||
fillEditor(data.post);
|
||||
await loadPosts();
|
||||
el.editorMessage.textContent = "已开始构建";
|
||||
|
||||
if (data.buildJob?.id) {
|
||||
pollBuildJob(data.buildJob.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollBuildJob(id) {
|
||||
for (;;) {
|
||||
await wait(1400);
|
||||
const data = await api(`/build-jobs/${id}`);
|
||||
const job = data.buildJob;
|
||||
el.editorMessage.textContent = `构建状态:${buildStatusText(job.status)}`;
|
||||
if (["success", "failed", "cancelled"].includes(job.status)) {
|
||||
if (job.error) {
|
||||
el.editorMessage.textContent = `构建失败:${job.error}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function statusText(status) {
|
||||
return {
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
archived: "归档",
|
||||
deleted: "已删除",
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
function buildStatusText(status) {
|
||||
return {
|
||||
queued: "等待中",
|
||||
running: "构建中",
|
||||
success: "成功",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "无时间";
|
||||
}
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function parseTags(value) {
|
||||
const seen = new Set();
|
||||
return value
|
||||
.split(/[,,]/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => {
|
||||
const key = tag.toLowerCase();
|
||||
if (!tag || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
el.loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
el.loginMessage.textContent = "";
|
||||
|
||||
try {
|
||||
const data = await api("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: el.loginUsername.value.trim(),
|
||||
password: el.loginPassword.value,
|
||||
}),
|
||||
});
|
||||
state.user = data.user;
|
||||
showApp();
|
||||
await loadPosts();
|
||||
resetEditor();
|
||||
} catch (error) {
|
||||
el.loginMessage.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
el.logoutButton.addEventListener("click", async () => {
|
||||
await api("/logout", { method: "POST" });
|
||||
state.user = null;
|
||||
state.posts = [];
|
||||
state.currentPost = null;
|
||||
showLogin();
|
||||
});
|
||||
|
||||
el.newPostButton.addEventListener("click", resetEditor);
|
||||
el.statusFilter.addEventListener("change", loadPosts);
|
||||
|
||||
el.titleInput.addEventListener("input", () => {
|
||||
if (!state.currentPost && !el.slugInput.value.trim()) {
|
||||
el.slugInput.value = slugify(el.titleInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
el.postForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await savePost();
|
||||
} catch (error) {
|
||||
el.editorMessage.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
el.publishButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await publishPost();
|
||||
} catch (error) {
|
||||
el.editorMessage.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
bootstrap();
|
||||
115
backend/internal/admin/web/index.html
Normal file
115
backend/internal/admin/web/index.html
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Osaet Admin</title>
|
||||
<link rel="stylesheet" href="/admin/assets/admin.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section id="loginView" class="login-view" hidden>
|
||||
<form id="loginForm" class="login-panel">
|
||||
<div>
|
||||
<p class="eyebrow">Osaet Admin</p>
|
||||
<h1>登录后台</h1>
|
||||
</div>
|
||||
<label>
|
||||
用户名
|
||||
<input id="loginUsername" autocomplete="username" value="yarnom" />
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input id="loginPassword" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
<button type="submit">登录</button>
|
||||
<p id="loginMessage" class="message" role="status"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="appView" hidden>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Osaet Admin</p>
|
||||
<h1>文章管理</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="userBadge" class="user-badge"></span>
|
||||
<button id="newPostButton" type="button">新文章</button>
|
||||
<button id="logoutButton" type="button" class="ghost">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="workspace">
|
||||
<aside class="post-list-panel">
|
||||
<div class="panel-heading">
|
||||
<h2>文章</h2>
|
||||
<select id="statusFilter" aria-label="文章状态">
|
||||
<option value="">全部</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="archived">归档</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="postList" class="post-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="editor-panel">
|
||||
<form id="postForm" class="editor-form">
|
||||
<div class="editor-head">
|
||||
<div>
|
||||
<p id="editorMode" class="eyebrow">新文章</p>
|
||||
<h2 id="editorTitle">开始写作</h2>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button id="saveButton" type="submit">保存</button>
|
||||
<button id="publishButton" type="button" class="publish">发布</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fields-grid">
|
||||
<label>
|
||||
标题
|
||||
<input id="titleInput" required />
|
||||
</label>
|
||||
<label>
|
||||
Slug
|
||||
<input id="slugInput" required />
|
||||
</label>
|
||||
<label>
|
||||
状态
|
||||
<select id="statusInput">
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="archived">归档</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
封面
|
||||
<input id="coverInput" />
|
||||
</label>
|
||||
<label class="wide-field">
|
||||
标签
|
||||
<input id="tagsInput" placeholder="用逗号分隔,例如:生活, 技术" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
摘要
|
||||
<textarea id="summaryInput" rows="3"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="body-field">
|
||||
正文 Markdown
|
||||
<textarea id="bodyInput" spellcheck="false"></textarea>
|
||||
</label>
|
||||
|
||||
<p id="editorMessage" class="message" role="status"></p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/admin/assets/admin.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -24,6 +24,14 @@ type Config struct {
|
|||
Model string
|
||||
}
|
||||
|
||||
type LocalConfig struct {
|
||||
URL string
|
||||
Model string
|
||||
Temperature float64
|
||||
TopP float64
|
||||
NumPredict int
|
||||
}
|
||||
|
||||
type deepSeekMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
|
|
@ -58,6 +66,24 @@ type slugResponse struct {
|
|||
Alternatives []string `json:"alternatives"`
|
||||
}
|
||||
|
||||
type localGenerateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
Options localOptions `json:"options"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
type localOptions struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
TopP float64 `json:"top_p"`
|
||||
NumPredict int `json:"num_predict"`
|
||||
}
|
||||
|
||||
type localGenerateResponse struct {
|
||||
Response string `json:"response"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func GenerateSlug(ctx context.Context, config Config, title string, summary string) (string, error) {
|
||||
apiKey := strings.TrimSpace(config.APIKey)
|
||||
if apiKey == "" {
|
||||
|
|
@ -168,6 +194,85 @@ JSON format: {"slug":"example-slug","alternatives":["another-slug"]}`,
|
|||
return slug, nil
|
||||
}
|
||||
|
||||
func GenerateLocalSlug(ctx context.Context, config LocalConfig, title string, summary string) (string, error) {
|
||||
url := strings.TrimSpace(config.URL)
|
||||
if url == "" {
|
||||
return "", errors.New("local LLM URL is empty")
|
||||
}
|
||||
model := strings.TrimSpace(config.Model)
|
||||
if model == "" {
|
||||
return "", errors.New("local LLM model is empty")
|
||||
}
|
||||
temperature := config.Temperature
|
||||
if temperature == 0 {
|
||||
temperature = 0.1
|
||||
}
|
||||
topP := config.TopP
|
||||
if topP == 0 {
|
||||
topP = 0.8
|
||||
}
|
||||
numPredict := config.NumPredict
|
||||
if numPredict == 0 {
|
||||
numPredict = 32
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Convert the following Chinese blog title into a concise English URL slug.
|
||||
Output only the slug. Lowercase only. Use hyphens. Max 8 words.
|
||||
Title: %s`, title)
|
||||
if strings.TrimSpace(summary) != "" {
|
||||
prompt += "\nSummary: " + summary
|
||||
}
|
||||
|
||||
payload := localGenerateRequest{
|
||||
Model: model,
|
||||
Stream: false,
|
||||
Options: localOptions{
|
||||
Temperature: temperature,
|
||||
TopP: topP,
|
||||
NumPredict: numPredict,
|
||||
},
|
||||
Prompt: prompt,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("local LLM returned %s: %s", resp.Status, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
var generated localGenerateResponse
|
||||
if err := json.Unmarshal(respBody, &generated); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if generated.Error != "" {
|
||||
return "", fmt.Errorf("local LLM error: %s", generated.Error)
|
||||
}
|
||||
slug := sanitizeSlug(generated.Response)
|
||||
if slug == "" {
|
||||
return "", errors.New("local LLM returned an empty slug")
|
||||
}
|
||||
return slug, nil
|
||||
}
|
||||
|
||||
func sanitizeSlug(slug string) string {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
|
|
|||
|
|
@ -95,10 +95,7 @@ Usage:
|
|||
osaetctl tags list [--all]
|
||||
osaetctl db init [--path .osaet/osaet.db]
|
||||
osaetctl db status [--path .osaet/osaet.db]
|
||||
osaetctl config import [--db .osaet/osaet.db]
|
||||
osaetctl config export [--db .osaet/osaet.db] [--overwrite]
|
||||
osaetctl config diff [--db .osaet/osaet.db]
|
||||
osaetctl config sync [--from file|db|auto] [--yes] [--db .osaet/osaet.db]
|
||||
osaetctl config
|
||||
osaetctl dev [--host 127.0.0.1] [--port 4321]
|
||||
osaetctl build
|
||||
osaetctl serve [--host 127.0.0.1] [--port 4321] [--dir dist/site]
|
||||
|
|
|
|||
|
|
@ -12,20 +12,9 @@ import (
|
|||
|
||||
func runConfig(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing config subcommand")
|
||||
}
|
||||
switch args[0] {
|
||||
case "import":
|
||||
return runConfigImport(root, args[1:])
|
||||
case "export":
|
||||
return runConfigExport(root, args[1:])
|
||||
case "diff":
|
||||
return runConfigDiff(root, args[1:])
|
||||
case "sync":
|
||||
return runConfigSync(root, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown config subcommand %q", args[0])
|
||||
return errors.New("site config is local-file-only; edit config/site.yaml directly")
|
||||
}
|
||||
return fmt.Errorf("site config is local-file-only; edit config/site.yaml directly (unsupported subcommand %q)", args[0])
|
||||
}
|
||||
|
||||
func loadLocalConfig(root string) (localConfig, error) {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,19 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChangedConfigFields(t *testing.T) {
|
||||
var a siteConfigFile
|
||||
a.Meta.ConfigVersion = 1
|
||||
a.Meta.UpdatedAt = "2026-05-28T12:00:00+08:00"
|
||||
a.Site.Title = "A"
|
||||
a.Content.PostsDir = "content/posts"
|
||||
a.Build.OutputDir = "dist/site"
|
||||
|
||||
b := a
|
||||
if fields := changedConfigFields(a, b); len(fields) != 0 {
|
||||
t.Fatalf("unchanged config fields = %#v", fields)
|
||||
func TestRunConfigReportsLocalOnly(t *testing.T) {
|
||||
err := runConfig(t.TempDir(), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "local-file-only") {
|
||||
t.Fatalf("expected local-file-only error, got %v", err)
|
||||
}
|
||||
|
||||
b.Site.Title = "B"
|
||||
b.Build.OutputDir = "dist/other"
|
||||
fields := changedConfigFields(a, b)
|
||||
if len(fields) != 2 || fields[0] != "site.title" || fields[1] != "build.output_dir" {
|
||||
t.Fatalf("changed fields = %#v", fields)
|
||||
err = runConfig(t.TempDir(), []string{"sync"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported subcommand \"sync\"") {
|
||||
t.Fatalf("expected unsupported subcommand error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,122 +9,8 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func runConfigImport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config import", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, dbPath, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
config, err := readSiteConfig(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertSetting(db, "site", config.Meta.ConfigVersion, config.Meta.UpdatedAt, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("imported config/site.yaml into %s\n", mustRel(root, dbPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigExport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config export", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
overwrite := fs.Bool("overwrite", false, "overwrite config/site.yaml")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
config, ok, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("settings.site not found; run `osaetctl config import` first")
|
||||
}
|
||||
|
||||
path := filepath.Join(root, "config/site.yaml")
|
||||
if _, err := os.Stat(path); err == nil && !*overwrite {
|
||||
return errors.New("config/site.yaml exists; pass --overwrite to replace it")
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeSiteConfig(root, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("exported settings.site into config/site.yaml")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigDiff(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return printConfigDiff(root, db)
|
||||
}
|
||||
|
||||
func runConfigSync(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config sync", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
from := fs.String("from", "", "sync source: file, db, or auto")
|
||||
yes := fs.Bool("yes", false, "confirm automatic sync without prompting")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
switch *from {
|
||||
case "file", "files":
|
||||
return syncConfigFromFile(root, db)
|
||||
case "db":
|
||||
return syncConfigFromDB(root, db)
|
||||
case "auto":
|
||||
return syncConfigAuto(root, db)
|
||||
case "":
|
||||
if err := printConfigDiff(root, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if !*yes && !confirm("Auto Sync config by newer updated_at?") {
|
||||
fmt.Println("No changes applied.")
|
||||
fmt.Println("Use `osaetctl config sync --from file` to write config/site.yaml into SQLite.")
|
||||
fmt.Println("Use `osaetctl config sync --from db` to write SQLite settings into config/site.yaml.")
|
||||
return nil
|
||||
}
|
||||
return syncConfigAuto(root, db)
|
||||
default:
|
||||
return errors.New("--from must be file, db, or auto")
|
||||
}
|
||||
}
|
||||
|
||||
func runPostsImport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts import", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
|
@ -445,138 +331,6 @@ func syncAuto(root string, db *sql.DB) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func upsertSetting(db *sql.DB, key string, version int, updatedAt string, value any) error {
|
||||
if version == 0 {
|
||||
version = 1
|
||||
}
|
||||
if strings.TrimSpace(updatedAt) == "" {
|
||||
updatedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
valueJSON, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO settings (key, value_json, version, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value_json = excluded.value_json,
|
||||
version = excluded.version,
|
||||
updated_at = excluded.updated_at`,
|
||||
key, string(valueJSON), version, updatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadSiteSetting(db *sql.DB) (siteConfigFile, bool, error) {
|
||||
var config siteConfigFile
|
||||
var valueJSON string
|
||||
err := db.QueryRow(`SELECT value_json FROM settings WHERE key = 'site'`).Scan(&valueJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return config, false, nil
|
||||
}
|
||||
return config, false, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(valueJSON), &config); err != nil {
|
||||
return config, false, err
|
||||
}
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
func printConfigDiff(root string, db *sql.DB) error {
|
||||
fileConfig, fileErr := readSiteConfig(root)
|
||||
dbConfig, dbOK, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case fileErr != nil && !errors.Is(fileErr, os.ErrNotExist):
|
||||
return fileErr
|
||||
case errors.Is(fileErr, os.ErrNotExist) && !dbOK:
|
||||
fmt.Println("summary: file=no db=no")
|
||||
case errors.Is(fileErr, os.ErrNotExist):
|
||||
fmt.Println("only-db settings.site")
|
||||
fmt.Println("summary: file=no db=yes")
|
||||
case !dbOK:
|
||||
fmt.Println("only-file config/site.yaml")
|
||||
fmt.Println("summary: file=yes db=no")
|
||||
default:
|
||||
fields := changedConfigFields(fileConfig, dbConfig)
|
||||
if len(fields) == 0 {
|
||||
fmt.Println("same config/site.yaml <-> settings.site")
|
||||
fmt.Println("summary: same=1 changed=0")
|
||||
} else {
|
||||
fmt.Println("changed config/site.yaml <-> settings.site")
|
||||
for _, field := range fields {
|
||||
fmt.Printf(" - %s\n", field)
|
||||
}
|
||||
fmt.Println("summary: same=0 changed=1")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncConfigFromFile(root string, db *sql.DB) error {
|
||||
config, err := readSiteConfig(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertSetting(db, "site", config.Meta.ConfigVersion, config.Meta.UpdatedAt, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("synced config/site.yaml into settings.site")
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncConfigFromDB(root string, db *sql.DB) error {
|
||||
config, ok, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("settings.site not found; run `osaetctl config import` first")
|
||||
}
|
||||
if err := writeSiteConfig(root, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("synced settings.site into config/site.yaml")
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncConfigAuto(root string, db *sql.DB) error {
|
||||
fileConfig, fileErr := readSiteConfig(root)
|
||||
dbConfig, dbOK, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case fileErr != nil && !errors.Is(fileErr, os.ErrNotExist):
|
||||
return fileErr
|
||||
case errors.Is(fileErr, os.ErrNotExist) && !dbOK:
|
||||
fmt.Println("nothing to sync")
|
||||
case errors.Is(fileErr, os.ErrNotExist):
|
||||
return syncConfigFromDB(root, db)
|
||||
case !dbOK:
|
||||
return syncConfigFromFile(root, db)
|
||||
default:
|
||||
if len(changedConfigFields(fileConfig, dbConfig)) == 0 {
|
||||
fmt.Println("config already in sync")
|
||||
return nil
|
||||
}
|
||||
fileTime, fileOK := parseTime(fileConfig.Meta.UpdatedAt)
|
||||
dbTime, dbTimeOK := parseTime(dbConfig.Meta.UpdatedAt)
|
||||
if !fileOK && !dbTimeOK {
|
||||
return errors.New("cannot auto sync config: both updated_at values are invalid")
|
||||
}
|
||||
if fileOK && (!dbTimeOK || fileTime.After(dbTime)) {
|
||||
return syncConfigFromFile(root, db)
|
||||
}
|
||||
return syncConfigFromDB(root, db)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSQLitePost(root string, db *sql.DB, post postFile) error {
|
||||
tagsJSON, err := json.Marshal(post.Frontmatter.Tags)
|
||||
if err != nil {
|
||||
|
|
@ -802,44 +556,3 @@ func changedPostFields(root string, filePost postFile, dbPost postFile) []string
|
|||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func changedConfigFields(fileConfig siteConfigFile, dbConfig siteConfigFile) []string {
|
||||
var fields []string
|
||||
if fileConfig.Meta.ConfigVersion != dbConfig.Meta.ConfigVersion {
|
||||
fields = append(fields, "meta.config_version")
|
||||
}
|
||||
if fileConfig.Meta.UpdatedAt != dbConfig.Meta.UpdatedAt {
|
||||
fields = append(fields, "meta.updated_at")
|
||||
}
|
||||
if fileConfig.Meta.UpdatedBy != dbConfig.Meta.UpdatedBy {
|
||||
fields = append(fields, "meta.updated_by")
|
||||
}
|
||||
if fileConfig.Site.Title != dbConfig.Site.Title {
|
||||
fields = append(fields, "site.title")
|
||||
}
|
||||
if fileConfig.Site.Description != dbConfig.Site.Description {
|
||||
fields = append(fields, "site.description")
|
||||
}
|
||||
if fileConfig.Site.BaseURL != dbConfig.Site.BaseURL {
|
||||
fields = append(fields, "site.base_url")
|
||||
}
|
||||
if fileConfig.Site.Language != dbConfig.Site.Language {
|
||||
fields = append(fields, "site.language")
|
||||
}
|
||||
if fileConfig.Site.Timezone != dbConfig.Site.Timezone {
|
||||
fields = append(fields, "site.timezone")
|
||||
}
|
||||
if fileConfig.Content.PostsDir != dbConfig.Content.PostsDir {
|
||||
fields = append(fields, "content.posts_dir")
|
||||
}
|
||||
if fileConfig.Content.AssetsDir != dbConfig.Content.AssetsDir {
|
||||
fields = append(fields, "content.assets_dir")
|
||||
}
|
||||
if fileConfig.Build.AstroProject != dbConfig.Build.AstroProject {
|
||||
fields = append(fields, "build.astro_project")
|
||||
}
|
||||
if fileConfig.Build.OutputDir != dbConfig.Build.OutputDir {
|
||||
fields = append(fields, "build.output_dir")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
|
|
|||
460
backend/internal/postimport/import.go
Normal file
460
backend/internal/postimport/import.go
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
package postimport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultPostsDir = "content/posts"
|
||||
|
||||
type Options struct {
|
||||
CSVPath string
|
||||
PostsDir string
|
||||
Overwrite bool
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Imported int
|
||||
SkippedExisting int
|
||||
SkippedNonPost int
|
||||
}
|
||||
|
||||
type PostFile struct {
|
||||
Path string
|
||||
Frontmatter PostFrontmatter
|
||||
Body string
|
||||
}
|
||||
|
||||
type PostFrontmatter struct {
|
||||
ID string `yaml:"id"`
|
||||
Slug string `yaml:"slug"`
|
||||
Title string `yaml:"title"`
|
||||
Summary string `yaml:"summary"`
|
||||
Status string `yaml:"status"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Cover string `yaml:"cover"`
|
||||
Version int `yaml:"version"`
|
||||
SlugSource string `yaml:"slug_source"`
|
||||
SlugLocked bool `yaml:"slug_locked"`
|
||||
PublishedAt *string `yaml:"published_at"`
|
||||
CreatedAt string `yaml:"created_at"`
|
||||
UpdatedAt string `yaml:"updated_at"`
|
||||
}
|
||||
|
||||
type csvArticle struct {
|
||||
ID string
|
||||
Slug string
|
||||
Title string
|
||||
BodyMD string
|
||||
BodyHTML string
|
||||
Status string
|
||||
ArchiveID string
|
||||
AuthorID string
|
||||
PublishedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
Type string
|
||||
}
|
||||
|
||||
func Import(options Options) (Result, error) {
|
||||
root, err := findProjectRoot(options.WorkingDir)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
csvPath := resolveRootPath(root, firstNonEmpty(options.CSVPath, "articles.csv"))
|
||||
postsDir := resolveRootPath(root, firstNonEmpty(options.PostsDir, defaultPostsDir))
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
file, err := os.Open(csvPath)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.LazyQuotes = false
|
||||
|
||||
headers, err := reader.Read()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
index := map[string]int{}
|
||||
for i, header := range headers {
|
||||
index[strings.TrimSpace(header)] = i
|
||||
}
|
||||
|
||||
var result Result
|
||||
for rowNum := 2; ; rowNum++ {
|
||||
record, err := reader.Read()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return result, fmt.Errorf("%s row %d: %w", csvPath, rowNum, err)
|
||||
}
|
||||
|
||||
article := csvArticle{
|
||||
ID: csvValue(record, index, "id"),
|
||||
Slug: csvValue(record, index, "slug"),
|
||||
Title: csvValue(record, index, "title"),
|
||||
BodyMD: csvValue(record, index, "body_md"),
|
||||
BodyHTML: csvValue(record, index, "body_html"),
|
||||
Status: csvValue(record, index, "status"),
|
||||
ArchiveID: csvValue(record, index, "archive_id"),
|
||||
AuthorID: csvValue(record, index, "author_id"),
|
||||
PublishedAt: csvValue(record, index, "published_at"),
|
||||
CreatedAt: csvValue(record, index, "created_at"),
|
||||
UpdatedAt: csvValue(record, index, "updated_at"),
|
||||
Type: csvValue(record, index, "type"),
|
||||
}
|
||||
|
||||
if strings.TrimSpace(article.Type) != "" && strings.TrimSpace(article.Type) != "post" {
|
||||
result.SkippedNonPost++
|
||||
continue
|
||||
}
|
||||
|
||||
post, skippedExisting, err := articleToPost(root, postsDir, article, options.Overwrite)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err)
|
||||
}
|
||||
if skippedExisting {
|
||||
result.SkippedExisting++
|
||||
continue
|
||||
}
|
||||
if err := writePostFile(post); err != nil {
|
||||
return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err)
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func articleToPost(root string, postsDir string, article csvArticle, overwrite bool) (PostFile, bool, error) {
|
||||
id := strings.TrimSpace(article.ID)
|
||||
if id == "" {
|
||||
id = randomID()
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(article.Title)
|
||||
if title == "" {
|
||||
return PostFile{}, false, errors.New("missing title")
|
||||
}
|
||||
|
||||
slug := sanitizeSlug(article.Slug)
|
||||
if slug == "" {
|
||||
slug = fallbackSlug(title)
|
||||
}
|
||||
if slug == "" {
|
||||
return PostFile{}, false, errors.New("missing slug")
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(article.Status)
|
||||
if status != "published" && status != "draft" {
|
||||
status = "draft"
|
||||
}
|
||||
|
||||
createdAt, err := normalizeLegacyTime(article.CreatedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid created_at: %w", err)
|
||||
}
|
||||
updatedAt, err := normalizeLegacyTime(article.UpdatedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid updated_at: %w", err)
|
||||
}
|
||||
|
||||
var publishedAt *string
|
||||
if strings.TrimSpace(article.PublishedAt) != "" {
|
||||
normalized, err := normalizeLegacyTime(article.PublishedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid published_at: %w", err)
|
||||
}
|
||||
publishedAt = &normalized
|
||||
}
|
||||
|
||||
path := filepath.Join(postsDir, slug+".md")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if !overwrite {
|
||||
return PostFile{}, true, nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
|
||||
finalSlug := slug
|
||||
if !overwrite {
|
||||
finalSlug, err = uniqueSlug(root, slug)
|
||||
if err != nil {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
path, err = uniquePostPath(postsDir, finalSlug)
|
||||
if err != nil {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.TrimLeft(article.BodyMD, "\n")
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = strings.TrimLeft(article.BodyHTML, "\n")
|
||||
}
|
||||
|
||||
return PostFile{
|
||||
Path: path,
|
||||
Frontmatter: PostFrontmatter{
|
||||
ID: id,
|
||||
Slug: finalSlug,
|
||||
Title: title,
|
||||
Summary: "",
|
||||
Status: status,
|
||||
Tags: []string{},
|
||||
Cover: "",
|
||||
Version: 1,
|
||||
SlugSource: "manual",
|
||||
SlugLocked: true,
|
||||
PublishedAt: publishedAt,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
Body: body,
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
func csvValue(record []string, index map[string]int, key string) string {
|
||||
i, ok := index[key]
|
||||
if !ok || i >= len(record) {
|
||||
return ""
|
||||
}
|
||||
return record[i]
|
||||
}
|
||||
|
||||
func normalizeLegacyTime(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", errors.New("empty time")
|
||||
}
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05.999999999Z07",
|
||||
"2006-01-02 15:04:05.999999Z07",
|
||||
"2006-01-02 15:04:05Z07",
|
||||
time.RFC3339,
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, value)
|
||||
if err == nil {
|
||||
return parsed.Format(time.RFC3339), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unsupported time format %q", value)
|
||||
}
|
||||
|
||||
func writePostFile(post PostFile) error {
|
||||
var frontmatter bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&frontmatter)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(post.Frontmatter); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
output.WriteString("---\n")
|
||||
output.Write(frontmatter.Bytes())
|
||||
output.WriteString("---\n\n")
|
||||
output.WriteString(strings.TrimLeft(post.Body, "\n"))
|
||||
|
||||
tmp := post.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, post.Path)
|
||||
}
|
||||
|
||||
func loadPosts(root string, postsDir string) ([]PostFile, error) {
|
||||
dir := resolveRootPath(root, postsDir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var posts []PostFile
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, PostFile{Path: filepath.Join(dir, entry.Name()), Frontmatter: PostFrontmatter{Slug: strings.TrimSuffix(entry.Name(), ".md")}})
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func slugExists(root string, slug string) bool {
|
||||
posts, err := loadPosts(root, defaultPostsDir)
|
||||
if err == nil {
|
||||
for _, post := range posts {
|
||||
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
|
||||
if post.Frontmatter.Slug == slug || fileSlug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(root, defaultPostsDir, slug+".md"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func uniqueSlug(root string, slug string) (string, error) {
|
||||
base := sanitizeSlug(slug)
|
||||
if base == "" {
|
||||
return "", errors.New("empty slug")
|
||||
}
|
||||
if !slugExists(root, base) {
|
||||
return base, nil
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d", base, i)
|
||||
if !slugExists(root, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available slug for %q", base)
|
||||
}
|
||||
|
||||
func uniquePostPath(postsDir string, slug string) (string, error) {
|
||||
candidate := filepath.Join(postsDir, slug+".md")
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate = filepath.Join(postsDir, fmt.Sprintf("%s-%d.md", slug, i))
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available filename for slug %q", slug)
|
||||
}
|
||||
|
||||
func findProjectRoot(start string) (string, error) {
|
||||
wd := start
|
||||
if wd == "" {
|
||||
var err error
|
||||
wd, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
for {
|
||||
if isProjectRoot(wd) {
|
||||
return wd, nil
|
||||
}
|
||||
parent := filepath.Dir(wd)
|
||||
if parent == wd {
|
||||
return "", errors.New("could not find project root")
|
||||
}
|
||||
wd = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isProjectRoot(dir string) bool {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "cmd", "osaetctl")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "frontend", "site", "package.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveRootPath(root string, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(root, path)
|
||||
}
|
||||
|
||||
func fallbackSlug(title string) string {
|
||||
var words []string
|
||||
var b strings.Builder
|
||||
flush := func() {
|
||||
if b.Len() > 0 {
|
||||
words = append(words, b.String())
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
for _, r := range strings.ToLower(title) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/':
|
||||
flush()
|
||||
default:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if len(words) == 0 {
|
||||
return "post-" + time.Now().Format("20060102150405")
|
||||
}
|
||||
return sanitizeSlug(strings.Join(words, "-"))
|
||||
}
|
||||
|
||||
func sanitizeSlug(slug string) string {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
slug = re.ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
for strings.Contains(slug, "--") {
|
||||
slug = strings.ReplaceAll(slug, "--", "-")
|
||||
}
|
||||
if len(slug) > 80 {
|
||||
slug = strings.Trim(slug[:80], "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("post-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
125
backend/internal/postimport/import_test.go
Normal file
125
backend/internal/postimport/import_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package postimport
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeLegacyTime(t *testing.T) {
|
||||
got, err := normalizeLegacyTime("2026-01-13 01:25:27.486491+00")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "2026-01-13T01:25:27Z" {
|
||||
t.Fatalf("normalized time = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleToPostFallbackSlugAndBody(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
post, skipped, err := articleToPost(root, postsDir, csvArticle{
|
||||
Title: "Hello World",
|
||||
BodyMD: "\nBody\n",
|
||||
Status: "published",
|
||||
CreatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
UpdatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
PublishedAt: "2026-01-13 01:25:27.486491+00",
|
||||
Type: "post",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skipped {
|
||||
t.Fatal("expected importable post")
|
||||
}
|
||||
if post.Frontmatter.Slug != "hello-world" {
|
||||
t.Fatalf("slug = %q", post.Frontmatter.Slug)
|
||||
}
|
||||
if post.Body != "Body\n" {
|
||||
t.Fatalf("body = %q", post.Body)
|
||||
}
|
||||
if post.Frontmatter.PublishedAt == nil || *post.Frontmatter.PublishedAt != "2026-01-13T01:25:27Z" {
|
||||
t.Fatalf("published_at = %#v", post.Frontmatter.PublishedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleToPostSkipsExistingWithoutOverwrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(postsDir, "smoking.md"), []byte("existing"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, skipped, err := articleToPost(root, postsDir, csvArticle{
|
||||
Slug: "smoking",
|
||||
Title: "抽烟",
|
||||
BodyMD: "Body",
|
||||
Status: "published",
|
||||
CreatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
UpdatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
Type: "post",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !skipped {
|
||||
t.Fatal("expected existing file to be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportWritesMarkdown(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "backend", "cmd", "osaetctl"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "frontend", "site"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "frontend", "site", "package.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csvPath := filepath.Join(root, "articles.csv")
|
||||
csvContent := strings.Join([]string{
|
||||
"id,slug,title,body_md,body_html,status,archive_id,author_id,published_at,created_at,updated_at,type",
|
||||
"post-1,test-post,Test Post,\"Line 1\n\nLine 2\",,published,,,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,post",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(csvPath, []byte(csvContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := Import(Options{CSVPath: csvPath, PostsDir: defaultPostsDir, WorkingDir: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Imported != 1 || result.SkippedExisting != 0 || result.SkippedNonPost != 0 {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(root, defaultPostsDir, "test-post.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
for _, want := range []string{
|
||||
"slug: test-post",
|
||||
"title: Test Post",
|
||||
"status: published",
|
||||
"published_at: \"2026-01-13T01:25:27Z\"",
|
||||
"Line 1",
|
||||
"Line 2",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected output to contain %q\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
100
backend/migrations/001_admin_schema.sql
Normal file
100
backend/migrations/001_admin_schema.sql
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
body_markdown TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
cover TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
slug_source TEXT NOT NULL DEFAULT 'manual',
|
||||
slug_locked BOOLEAN NOT NULL DEFAULT false,
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT posts_status_check CHECK (status IN ('draft', 'published', 'archived', 'deleted')),
|
||||
CONSTRAINT posts_version_check CHECK (version >= 1)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_published_at ON posts(published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_updated_at ON posts(updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
body_markdown TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT post_versions_status_check CHECK (status IN ('draft', 'published', 'archived', 'deleted')),
|
||||
CONSTRAINT post_versions_reason_check CHECK (reason IN ('save', 'publish', 'unpublish', 'archive', 'restore', 'import', 'rollback')),
|
||||
CONSTRAINT post_versions_version_check CHECK (version >= 1),
|
||||
UNIQUE (post_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_post_versions_post_id_created_at ON post_versions(post_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_tags (
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (post_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_post_tags_tag_id ON post_tags(tag_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS build_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
trigger TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
post_id UUID REFERENCES posts(id) ON DELETE SET NULL,
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
log TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT build_jobs_trigger_check CHECK (trigger IN ('publish', 'manual', 'import', 'sync')),
|
||||
CONSTRAINT build_jobs_status_check CHECK (status IN ('queued', 'running', 'success', 'failed', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_build_jobs_status_created_at ON build_jobs(status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_build_jobs_post_id_created_at ON build_jobs(post_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT assets_size_bytes_check CHECK (size_bytes >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_sha256 ON assets(sha256);
|
||||
11
backend/migrations/002_admin_sessions.sql
Normal file
11
backend/migrations/002_admin_sessions.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_sessions_user_id ON admin_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires_at ON admin_sessions(expires_at);
|
||||
|
|
@ -3,8 +3,18 @@ database:
|
|||
sqlite_path: ".osaet/osaet.db"
|
||||
postgres_dsn: ""
|
||||
|
||||
slug:
|
||||
provider: "deepseek" # deepseek | local
|
||||
|
||||
deepseek:
|
||||
api_key: ""
|
||||
api_key_env: "DEEPSEEK_API_KEY"
|
||||
base_url: "https://api.deepseek.com"
|
||||
model: "deepseek-v4-pro"
|
||||
|
||||
local_llm:
|
||||
url: "http://127.0.0.1:11434/api/generate"
|
||||
model: "qwen2.5:7b-instruct-q4_K_M"
|
||||
temperature: 0.1
|
||||
top_p: 0.8
|
||||
num_predict: 32
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ meta:
|
|||
updated_by: "cli"
|
||||
|
||||
site:
|
||||
title: "Osaet"
|
||||
description: "Personal blog"
|
||||
title: "冯梦华的日常"
|
||||
description: "Yarnom'Blog"
|
||||
base_url: "http://localhost:4321"
|
||||
language: "zh-CN"
|
||||
timezone: "Asia/Shanghai"
|
||||
theme: "yar"
|
||||
|
||||
content:
|
||||
posts_dir: "content/posts"
|
||||
|
|
|
|||
17
content/posts/2023-new-year.md
Normal file
17
content/posts/2023-new-year.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
id: c9fc4468-1554-426a-8f8d-e486e744126e
|
||||
slug: 2023-new-year
|
||||
title: 2023 新的一年
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-01T04:22:36+08:00"
|
||||
created_at: "2023-01-01T17:41:23+08:00"
|
||||
updated_at: "2026-01-07T18:10:59+08:00"
|
||||
---
|
||||
|
||||
致美好与苦难
|
||||
868
content/posts/arm-learning-summary.md
Normal file
868
content/posts/arm-learning-summary.md
Normal file
|
|
@ -0,0 +1,868 @@
|
|||
---
|
||||
id: ce774734-92c2-4e0d-986b-5d48b3bfe9f5
|
||||
slug: arm-learning-summary
|
||||
title: ARM 学习总结
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-11-23T11:13:27+08:00"
|
||||
created_at: "2022-11-23T11:13:27+08:00"
|
||||
updated_at: "2026-01-07T18:10:50+08:00"
|
||||
---
|
||||
|
||||
### 二、寻址方式
|
||||
|
||||
每种寻址方式可能还有其他的变形,但是在这一章中不做过多说明,会在下面对应的章节中给出。
|
||||
|
||||
#### 寄存器寻址
|
||||
|
||||
```assembly
|
||||
MOV R1,R2 ;R2 -> R1
|
||||
```
|
||||
|
||||
#### 立即寻址
|
||||
```assembly
|
||||
MOV R0,#0x123 ;0x123 -> R0
|
||||
```
|
||||
|
||||
#### 寄存器偏移寻址
|
||||
```assembly
|
||||
MOV R0,R1,LSL #2 ;R1 的值左移 2 位,结果送给R0,即 R2 * 4 -> R0
|
||||
```
|
||||
可采用的移位操作如下:
|
||||
|
||||
- **LSL**:逻辑左移(**Logical Shift Left**),寄存器中字的低端空出的位补 0
|
||||
- **LSR**:逻辑右移(**Logical Shift Right**),寄存器中字的高端空出的位补 0
|
||||
- **ASR**:算术右移(**Arithmetic Shift Right**),移位过程中保持符号位不变,即如
|
||||
果源操作数为正数,则字的高端空出的位补 0,否则补 1
|
||||
- **ROR**:循环右移(**Rotate Right**),由字的低端移出的位填入字的高端空出的位
|
||||
- **RRX**:带扩展的循环右移(**Rotate Right eXtended by 1place**),操作数右移一位,
|
||||
高端空出的位用原 C 标志值填充。
|
||||
|
||||
#### 寄存器间接寻址
|
||||
```assembly
|
||||
LDR R0,[R1] ;将 R1 中的数值作为地址,将这个地址的值取出给R0
|
||||
```
|
||||
|
||||
#### 基址寻址
|
||||
|
||||
```assembly
|
||||
LDR R2,[R3,#0x0F] ;将 R3 中的数值加 0x0F 作为地址,取出此地址的数值保存在 R2 中
|
||||
```
|
||||
|
||||
#### 多寄存器寻址
|
||||
#### 堆栈寻址
|
||||
|
||||
|
||||
|
||||
### 二、数据处理指令
|
||||
|
||||
#### 快速查阅表
|
||||
|
||||
| 编号 | 助记符号 | 说明 | 操作 |
|
||||
| :--: | :-------------------: | :-----------------: | :---------------------------: |
|
||||
| 0 | MOV Rd ,operand2 | 数据转送 | Rd←operand2 |
|
||||
| 1 | MVN Rd ,operand2 | 数据非转送 | Rd←(~operand2) |
|
||||
| 2 | ADD Rd,Rn operand2 | 加法运算指令 | Rd←Rn+operand2 |
|
||||
| 3 | SUB Rd,Rn operand2 | 减法运算指令 | Rd←Rn-operand2 |
|
||||
| 4 | RSB Rd,Rn operand2 | 逆向减法指令 | Rd←operand2-Rn |
|
||||
| 5 | ADC Rd,Rn operand2 | 带进位加法 | Rd←Rn+operand2+carry |
|
||||
| 6 | SBC Rd,Rn operand2 | 带进位减法指令 | Rd←Rn-operand2-(NOT)Carry |
|
||||
| 7 | RSC Rd,Rn operand2 | 带进位逆向减法指令 | Rd←operand2-Rn-(NOT)Carry |
|
||||
| 8 | AND Rd,Rn operand2 | 逻辑与操作指令 | Rd←Rn&operand2 |
|
||||
| 9 | ORR Rd,Rn operand2 | 逻辑或操作指令 | Rd←Rn\\|operand2 |
|
||||
| 10 | EOR Rd,Rn operand2 | 逻辑异或操作指令 | Rd←Rn^operand2 |
|
||||
| 11 | BIC Rd,Rn operand2 | 位清除指令 | Rd←Rn&(~operand2) |
|
||||
| 12 | CMP Rn,operand2 | 比较指令 | 标志 N、Z、C、V←Rn-operand2 C |
|
||||
| 13 | CMN Rn,operand2 | 负数比较指令 | N、Z、C、V←Rn+operand2 |
|
||||
| 14 | TST Rn,operand2 | 位测试指令 | 标志 N、Z、C、V←Rn&operand2 |
|
||||
| 15 | TEQ Rn,operand2 | 相等测试指令 | 标志 N、Z、C、V←Rn^operand2 |
|
||||
| 16 | MUL Rd,Rm,Rs | 32 位乘法指令 | Rd←Rm*Rs (Rd≠Rm) |
|
||||
| 17 | MLA Rd,Rm,Rs,Rn | 32 位乘加指令 | Rd←Rm*Rs+Rn (Rd≠Rm) |
|
||||
| 18 | UMULL RdLo,RdHi,Rm,Rs | 64 位无符号乘法指令 | (RdLo,RdHi)←Rm*Rs |
|
||||
| 19 | UMLAL RdLo,RdHi,Rm,Rs | 64 位无符号乘加指令 | (RdLo,RdHi)←Rm*Rs+(RdLo,RdHi) |
|
||||
| 20 | SMULL RdLo,RdHi,Rm,Rs | 64 位有符号乘法指令 | (RdLo,RdHi)←Rm*Rs |
|
||||
| 21 | SMLAL RdLo,RdHi,Rm,Rs | 64 位有符号乘加指令 | (RdLo,RdHi)←Rm*Rs+(RdLo,RdHi) |
|
||||
|
||||
在介绍指令之前,我们首先先来介绍影响CPSR中的一些标志位
|
||||
|
||||
- V 溢出标志位
|
||||
- C 进位或借位标志位
|
||||
- 对于加法指令(ADDS 和 CMN)如果产生进位,则C = 1
|
||||
- 对于减法指令 (SUBS 和 CMP )如果产生借位,则C = 0
|
||||
- Z 结果为0标志位
|
||||
- Z = 1 表示运算结果是 0
|
||||
- 同理
|
||||
|
||||
- N 符号标志位
|
||||
- N=1 表示运算结果为负数
|
||||
- 同理
|
||||
|
||||
|
||||
#### [0] MOV 数据转送指令
|
||||
|
||||
```assembly
|
||||
MOV{cond}{S} Rd,operand2
|
||||
|
||||
MOV R1,#0x12 ;R1=0x12
|
||||
MOV R2,R1,LSL #2 ;R2=R1 << 2
|
||||
MOVS R3,R2,LSL #4 ;R3=R2 << 4,并影响标志位
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [1] MVN 数据非转送指令
|
||||
|
||||
这个命令和MOV很像,只不过在传送之前,把操作数先取反了。
|
||||
|
||||
在使用这个命令的时候,请不要忘记 **ARM 的寄存器是 32位的**
|
||||
|
||||
```assembly
|
||||
MVN{cond}{S} Rd,operand2
|
||||
|
||||
MVN R1,#0xFF ;R1=0xFFFFFF00,这里的 0xFF 实际上是 0x000000FF
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [2] ADD 加法运算指令
|
||||
|
||||
```assembly
|
||||
ADD R1,R1,#0x13 ;R1 = R1 + 0x13
|
||||
ADDS R2,R1,#0x1 ;影响标志位
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [3] SUB 减法运算指令指令
|
||||
|
||||
```assembly
|
||||
SUB R0,R1,#0x12 ;R0=R1-0x12
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [4] RSB 逆向减法指令
|
||||
|
||||
```assembly
|
||||
RSB R3,R1,#0x12 ;R3=0x12-R1
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [5] ADC 带进位加法
|
||||
|
||||
带进位加法指令.将 operand2 的数据与 Rn 的值相加,再加上 CPSR 中的 C 条件标志位.结果保存到 Rd 寄存器.
|
||||
|
||||
由于寄存器是32位的,所以这个指令常用于计算64位加法。
|
||||
|
||||
***这里需要注意的是,在进行 ADDS 运算的时候,如果出现了进位,CPSR中的 C=1,否则 C=0***
|
||||
|
||||
**例如有这样的两个64位数:**
|
||||
|
||||
**假设R0和R1构成一个64位数,R0存放低32位,R1存放高32位;R2,R3构成一个64位数,R2存放低32位,R3存放高32位.**
|
||||
|
||||
计算的方法就是,先让两个低32位寄存器相加,为了得到可能的进位,需要用到ADDS,它能影响标志位
|
||||
|
||||
接着使用ADC,两个高32位相加,再加上标志位中的C 进位
|
||||
|
||||
```assembly
|
||||
LDR R0, =0XFFFFFFFF
|
||||
LDR R1, =0X12
|
||||
LDR R2, =0X1
|
||||
LDR R3, =0X2
|
||||
|
||||
ADDS R0,R0,R2 ;R0 = R0 + R1 也就是0xFFFFFFFF + 0x1 这得到的是 0x00000000 ,因为溢出了,但是有进位 C = 1
|
||||
ADC R1,R1,R3 ; R1 = R1 + R3 也就是 0x12 + 0x2 + 1 得到 0x15
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [6] SBC 带进位减法指令
|
||||
|
||||
带进位减法指令。用寄存器 Rn 减去 operand2,再减去 CPSR 中的 C 条件标志位的反码
|
||||
|
||||
***这里需要注意的是,在进行 SUBS 运算的时候,如果出现了借位,CPSR中的 C=0,否则 C=1***
|
||||
|
||||
SBC与ADC指令类似,常用于计算64位的减法。
|
||||
|
||||
**例如有这样的两个64位数:**
|
||||
|
||||
**假设R0和R1构成一个64位数,R0存放低32位,R1存放高32位;R2,R3构成一个64位数,R2存放低32位,R3存放高32位.**
|
||||
|
||||
```assembly
|
||||
LDR R0, =0X12
|
||||
LDR R1, =0X9
|
||||
LDR R2, =0X32
|
||||
LDR R3, =0X2
|
||||
|
||||
SUBS R0,R0,R2 ;R0 = R0 - R2 也就是 0x12 - 0x32 这将得到 0xFFFFFFE0 ,因为不够减,CPSR 中的 N=1,C=0
|
||||
SBC R1,R1,R3 ;R1 = R1 - R3 - !C 也就是 0x9 - 0x2 - !0 ,得到0x6
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [7] RSC 带进位逆向减法指令
|
||||
|
||||
用寄存器 operand2 减去 Rn,再减去 CPSR 中的 C 条件标志位的反码
|
||||
|
||||
**例如有这样的两个64位数:**
|
||||
|
||||
**假设R0和R1构成一个64位数,R0存放低32位,R1存放高32位;R2,R3构成一个64位数,R2存放低32位,R3存放高32位.**
|
||||
|
||||
```assembly
|
||||
LDR R0, =0X12
|
||||
LDR R1, =0X9
|
||||
LDR R2, =0X32
|
||||
LDR R3, =0X2
|
||||
|
||||
RSBS R0,R0,R2 ;R0 = R2-R0 也就是 0x32 - 0X12 这将得到 0x20 ,没有借位 ,CPSR 中的 N=1,C=1
|
||||
RBC R1,R1,R3 ;R1 = R3 - R2 - !C 也就是 0x2 - 0x9 - !1 ,得到0xFFFFFFF9
|
||||
```
|
||||
|
||||
这里值得注意一下,在计算机中负数是用补码保存的。
|
||||
|
||||
**2 - 9 = -7**
|
||||
|
||||
这个 -7 的原码在八位寄存器中是 10000111 , 反码是 11111000,补码是 11111001 ,也就是0xF9 ,同理,在32位寄存器中就是 0xFFFFFFFF9
|
||||
|
||||
|
||||
|
||||
所以,在了解了SUBS 和 SBC 之后,**我们同样可以求出 64 位的负数**,和上面的例子是一样的
|
||||
|
||||
**假设R0和R1构成一个64位数,R0存放低32位,R1存放高32位 ,求它的负数**
|
||||
|
||||
```assembly
|
||||
LDR R0, =0X12
|
||||
LDR R1, =0X9
|
||||
|
||||
RSBS R0,R0,#0 ;R0 = 0-R0 也就是 0 - 0X12 这将得到 0xFFFFFFEE ,有借位 ,CPSR 中的 N=1,C=0
|
||||
RBC R1,R1,#0 ;R1 = 0- R1 - !C 也就是 0 - 0x9 - !0 ,得到0xFFFFFFF6
|
||||
```
|
||||
|
||||
|
||||
|
||||
这里还是算一下,-10 的 补码
|
||||
|
||||
在八位寄存器中,-10 的原码表示是 1000 1010 ,反码表示是 1111 0101,补码表示是 1111 0110 ,也就是0xF6
|
||||
|
||||
在32位寄存器中的表示就是,0xFFFF FFF6
|
||||
|
||||
|
||||
|
||||
#### [8] AND 逻辑与操作指令
|
||||
|
||||
```assembly
|
||||
AND R0,R1,R2 ;R0=R1&R2
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [9] ORR 逻辑或操作指令
|
||||
|
||||
```assembly
|
||||
ORR R0,R1,R2 ;R0=R1|R2
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [10] EOR 逻辑异或操作指令
|
||||
|
||||
#### [11] BIC 位清除指令
|
||||
|
||||
位清除指令.将寄存器Rn的值与operand2的值的反码按位作逻辑与操作,结果保存 到 Rd 中
|
||||
|
||||
|
||||
|
||||
#### [12] CMP 比较指令
|
||||
|
||||
本质是 做减法 ,结果一定影响标志位
|
||||
|
||||
```assembly
|
||||
CMP R1,R2 ;R1-R2
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [13] CMN 负数比较指令
|
||||
|
||||
本质是 做加法 ,结果一定影响标志位
|
||||
|
||||
```assembly
|
||||
CMN R1,R2 ;R1+R2
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [14] TST 位测试指令
|
||||
|
||||
指令将寄存器Rn的值与operand2的值按位作逻辑与操作,根据操作的 结果理新 CPSR 中相应的条件标志位
|
||||
|
||||
````ass
|
||||
TST R0,#0x1 ;判断 R0 的最低位是否为 0
|
||||
````
|
||||
|
||||
|
||||
|
||||
#### [15] TEQ 位相等测试指令
|
||||
|
||||
指令寄存器Rn的值与operand2的值按位作逻辑异或操作,根据操作 的结果理新 CPSR 中相应条件标志位
|
||||
|
||||
```assembly
|
||||
TEQ R0,R1 ;比较 R0 与 R1 是否相等 ,与用cmp命令对比,它不影响(不影响 V 位和 C 位)
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [16] MUL 32 位乘法指令
|
||||
|
||||
指令将 Rm 和 Rs 中的值相乘,结果的低 32 位保存到 Rd 中
|
||||
|
||||
**MUL{cond}{S} Rd,Rm,Rs**
|
||||
|
||||
```assembly
|
||||
MUL R1,R2,R3 ;R1=R2×R3
|
||||
MULS R1,R2,R3 ;R0=R2×R3,同时设置 CPSR 中的 N 位和 Z 位
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [17] MLA 32 位乘加指令
|
||||
|
||||
指令将 Rm 和 Rs 中的值相乘,再将乘积加上第 3 个操作数,结果的低 32 位保存到 Rd 中
|
||||
|
||||
**MLA{cond}{S} Rd,Rm,Rs,Rn**
|
||||
|
||||
```ASS
|
||||
MLA R1,R2,R3,R4 ;R1=R2×R3+R4
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [18] UMULL 64 位无符号乘法指令
|
||||
|
||||
**U即 Unsigned 无符号**
|
||||
|
||||
指令将 Rm 和 Rs 中的值作无符号数相乘,结果的低 32 位保存 到 RsLo 中,而高 32 位保存到 RdHi 中
|
||||
|
||||
**UMULL{cond}{S} RdLo,RdHi,Rm,Rs**
|
||||
|
||||
```assembly
|
||||
UMULL R0,R1,R2,R3 ;(R1:R0)=R2×R3 ;相当于 R0 = (R2*R3) 的低32位,R1 = (R2*R3) 的高32位
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [19] UMLAL 64 位无符号乘加指令
|
||||
|
||||
**U即 Unsigned 无符号**
|
||||
|
||||
指令将 Rm 和 Rs 中的值作无符号数相乘,64 位乘积与 RdHi,RdLo 相加,结果的低 32 位保存到 RdLo 中,而高 32 位保存到 RdHi 中.
|
||||
|
||||
**UMLAL{cond}{S} RdLo,RdHi,Rm,Rs**
|
||||
|
||||
```assembly
|
||||
UMLAL R0,R1,R2,R3 ;(R1,R0)=R2×R3+(R1,R0) ;相当于 R0 = (R2*R3) 的低32位 + R0,R1 = (R2*R3) 的高32位+ R1
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [20] SMULL 64 位有符号乘法指令
|
||||
|
||||
**S即 Signed 有符号**
|
||||
|
||||
指令将 Rm 和 Rs 中的值作有符号数相乘,结果的低 32 位保存 到 RdLo 中,而高 32 位保存到 RdHi 中
|
||||
```assembly
|
||||
SMULL R0,R1,R2,R3 ;(R1:R0)=R2×R3 ;相当于 R0 = (R2*R3) 的低32位,R1 = (R2*R3) 的高32位
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [21] SMLAL 64 位有符号乘加指令
|
||||
|
||||
指令将 Rm 和 Rs 中的值作有符号数相乘,64 位乘积与RdHi,RdLo,相加,结果的低 32 位保存到 RdLo 中,而高 32 位保存到 RdHi 中.
|
||||
|
||||
```assembly
|
||||
SMLAL R0,R1,R2,R3 ;(R1,R0)=R2×R3+(R1,R0) ;相当于 R0 = (R2*R3) 的低32位 + R0,R1 = (R2*R3) 的高32位+ R1
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 三、ARM分支指令
|
||||
|
||||
在了解分支指令之前,我们首先得去了解一下**条件码**,否则我们就会没办法正确使用分支指令
|
||||
|
||||
在此给出条件码表格
|
||||
|
||||
| 条件码助记符 | 英文含义,助记符来源 | 查看的标志 | 中文含义 |
|
||||
| :----------: | :--------------------------------------------------------: | :--------: | :------------------------: |
|
||||
| EQ | **Eq**ual | Z=1 | 相等 |
|
||||
| NE | **N**ot **e**qual. | Z=0 | 不相等 |
|
||||
| CS/HS | Unsigned **h**igher or **s**ame (or **c**arry **s**et). | C=1 | 无符号数大于或等于/C位设置 |
|
||||
| CC/LO | Unsigned **lo**wer (or **c**arry **c**lear). | C=0 | 无符号数小于/C位清除 |
|
||||
| MI | Negative. The mnemonic stands for "**mi**nus". | N=1 | 负数 |
|
||||
| PL | Positive or zero. The mnemonic stands for "**pl**us". | N=0 | 正数或零 |
|
||||
| VS | Signed overflow. The mnemonic stands for "**V s**et". | V=1 | 溢出/V位设置 |
|
||||
| VC | No signed overflow. The mnemonic stands for "**V c**lear". | V=0 | 没有溢出 /V位清除 |
|
||||
| HI | Unsigned **hi**gher. | C=1,Z=0 | 无符号数大于 |
|
||||
| LS | Unsigned **l**ower or **s**ame. | C=0,Z=1 | 无符号数小于或等于 |
|
||||
| GE | Signed **g**reater than or **e**qual. | N=V | 带符号数大于或等于 |
|
||||
| LT | Signed **l**ess **t**han. | N!=V | 带符号数小于 |
|
||||
| GT | Signed **g**reater **t**han. | Z=0,N=V | 带符号数大于 |
|
||||
| LE | Signed **l**ess than or **e**qual. | Z=1,N!=V | 带符号数小于或等于 |
|
||||
| AL | Always executed. | 任何 | 无条件执行(指令默认条件) |
|
||||
|
||||
|
||||
|
||||
#### 快速记忆方法:
|
||||
|
||||
我们必须得结合英文才能快速地记住这些**“助记符”**
|
||||
|
||||
***尤其是无符号和有符号之间的比较。***
|
||||
|
||||
无符号一般会使用 **Lower 和 Higher**和 **Same**,有符号一般会使用 **Greater than** 和 **Less than** 和 **Equal**,
|
||||
|
||||
所以,**无符号的大于等于** HS = Higher + Same ,**无符号小于等于** LS = Lower + Same ,**无符号大于 HI** = Higher(前两个字母),**无符号小于** LO = Lower
|
||||
|
||||
同理的,**有符号大于等于** GE = Greater + Equal ,**有符号小于等于** LE = Less + Equal, **有符号大于**就是 GT = Greater + Than ,**有符号小于**就是 LT = Less + Than
|
||||
|
||||
|
||||
|
||||
所以,通过这个英文可以很快速地就记住。
|
||||
|
||||
|
||||
|
||||
#### 跳转分支指令
|
||||
|
||||
接下来,我们来说一下跳转指令,同样的,给出跳转指令快速查阅表:
|
||||
|
||||
| 助记符 | 说明 | 操作 |
|
||||
| :------: | :------------------: | :-------------------: |
|
||||
| B label | 跳转指令 | PC←label |
|
||||
| BL label | 带链接的跳转指令 | LR←PC-4, PC←label |
|
||||
| BX Rm | 带状态切换的跳转指令 | PC←label,切换处理状态 |
|
||||
|
||||
|
||||
|
||||
#### [1] B 跳转指令
|
||||
|
||||
**B{cond} label**
|
||||
|
||||
```assembly
|
||||
B LOOP_Y1 ;跳转到 LOOP_Y1 标号处
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### [2] BL 带链接的跳转指令
|
||||
|
||||
**BL{cond} label**
|
||||
|
||||
这个跳转的操作是:**LR←PC-4, PC←label**,由于将PC地址保持到了LR寄存器里面,所以之后还能跳转回来
|
||||
|
||||
|
||||
|
||||
#### [3] BX 带状态切换的跳转指令
|
||||
|
||||
略
|
||||
|
||||
|
||||
|
||||
### 四、加载和存储指令
|
||||
|
||||
**Load and Store with register offset.**
|
||||
|
||||
他们最基础的指令是 LDR 和 STR,以下先给出这两个基础指令的用法:
|
||||
|
||||
| 助记符 | 说明 | 操作 |
|
||||
| ------------------ | ---------- | --------------- |
|
||||
| LDR Rd, addressing | 加载字数据 | Rd←[addressing] |
|
||||
| STR Rd, addressing | 存储字数据 | [addressing]←Rd |
|
||||
| | | |
|
||||
|
||||
#### 寄存器间接寻址
|
||||
|
||||
```
|
||||
LDR R0,[R1] ;R0 <- [R1]
|
||||
STR R0,[R1] ;[R1] <- R0
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### 基址加变址寻址
|
||||
|
||||
这里有几种方式,前变址法、后变址法、自动变址
|
||||
|
||||
- 前变址法,也就是先变化地址,再根据这个地址 存取。
|
||||
|
||||
```assembly
|
||||
LDR R0,[R1,#4] ;R0 <- [R1 + 4]
|
||||
```
|
||||
|
||||
- 后变址,也就是先存取,再变化地址
|
||||
|
||||
```assembly
|
||||
LDR R0,[R1],#4 ;R0 <- [R1] 然后 R1<-R1+4
|
||||
```
|
||||
|
||||
- 自动变址,综合上面两种,加一个 感叹号 !
|
||||
|
||||
```assembly
|
||||
LDR R0,[R1,#4]! ;R0 <- [R1 + 4] 然后 R1<-R1+4
|
||||
```
|
||||
|
||||
STR 指令也是同理的,这里不再赘述。
|
||||
|
||||
在理解了基础指令之后,我们可以尝试去看看这两个指令的更多用法:
|
||||
|
||||
以下依旧给出速查表:
|
||||
|
||||
| 助记符 | 说明 | 操作 |
|
||||
| -------------------- | -------------------------- | --------------- |
|
||||
| LDR Rd, addressing | 加载字数据 | Rd←[addressing] |
|
||||
| LDRB Rd,addressing | 加载无符字节数据 | Rd←[addressing] |
|
||||
| LDRT Rd,addressing | 以用户模式加载字数据 | Rd←[addressing] |
|
||||
| LDRBT Rd,addressing | 以用户模式加载无符号字数据 | Rd←[addressing] |
|
||||
| LDRH Rd,addressing | 加载无符半字数据 | Rd←[addressing] |
|
||||
| LDRSB Rd,addressing | 加载有符字节数据 | Rd←[addressing] |
|
||||
| LDRSH Rd,addressing | 加载有符半字数据 | Rd←[addressing] |
|
||||
| | | |
|
||||
| STR Rd,addressing | 存储字数据 | [addressing]←Rd |
|
||||
| STRB Rd,addressing | 存储字节数据 | [addressing]←Rd |
|
||||
| STRT Rd,addressing | 以用户模式存储字数据 | [addressing]←Rd |
|
||||
| SRTBT Rd,addressing | 以用户模式存储字节数据 | [addressing]←Rd |
|
||||
| STRH Rd,addressing | 存储半字数据 | [addressing]←Rd |
|
||||
|
||||
|
||||
|
||||
虽然看起来蛮多的,但主要就是
|
||||
|
||||
- 后缀带有B的,是无符字节数据
|
||||
- 后缀带有H的,是无符半字数据
|
||||
- 后缀带有SB的,是有符号字节数据
|
||||
- 后缀带有SH的,是有符号半字数据
|
||||
|
||||
因为 字节是**B**yte ,半字是**H**alf Word,有符号是 **S**igned
|
||||
|
||||
|
||||
|
||||
### 五、加载和存储指令LDM 和 STM 批量加载和批量存储分析
|
||||
|
||||
这一段内容来自 http://blog.chinaunix.net/uid-29401328-id-5059312.html
|
||||
|
||||
这里是简单地进行搬运。
|
||||
|
||||
#### 普通用法和堆栈用法
|
||||
|
||||
|
||||
|
||||
> 当LDM/STM没有被用于堆栈,而只是简单地表示地址前向增加,后向增加,前向减少,后向减少时,由IA,IB,DA,DB控制。
|
||||
|
||||
- IA ----> Increment After 每次传送后地址加4
|
||||
- IB ----> Increment Before 每次传送前地址加4
|
||||
- DA ----> Decrement After 每次传送后地址减4
|
||||
- DB ----> Decrement Before 每次传送前地址减4
|
||||
|
||||
> 堆栈请求格式,FD,ED,FA,EA定义了前/后向索引和上/下位
|
||||
>
|
||||
> F,E表示堆栈满或者空。
|
||||
> A 和 D 定义堆栈是递增还是递减,如果递增,STM将向上,LDM向下,如果递减,则相反。
|
||||
|
||||
- FA ----> Full Ascending 满递增堆栈
|
||||
- FD ----> Full Descending 满递减堆栈
|
||||
- EA ----> Empty Ascending 空递增堆栈
|
||||
- ED ----> Empty Descending 空递减堆栈
|
||||
|
||||
|
||||
|
||||
#### 普通用法
|
||||
|
||||
```assembly
|
||||
STMIA R0!,{R1,R3,R5}
|
||||
LDMDB R0!,{R1-R3}
|
||||
```
|
||||
|
||||
保存的时候使用了 IA **后增加**的方式,取的时候就得用 DB **先减少** 的方式
|
||||
|
||||
这个例子的R0指向一段基地址
|
||||
|
||||
|
||||
|
||||
#### 堆栈用法
|
||||
|
||||
1. Full descending 满递减堆栈——FD 堆栈首部是高地址,堆栈向低地址增长。栈指针总是指向堆栈**最后一个元素**(最后
|
||||
一个元素是最后压入的数据)。ARM-Thumb过程调用标准和ARM、Thumb C/C++ 编译器总是使用Full descending 类型堆栈。
|
||||
|
||||
2. Full ascending 满递增堆栈——FA 堆栈首部是低地址,堆栈向高地址增长。栈指针总是指向堆栈**最后一个元素**(最后
|
||||
一个元素是最后压入的数据)。
|
||||
|
||||
3. Empty descending 空递减堆栈——ED 堆栈首部是高地址,堆栈向低地址增长。栈指针**总是指向下一个将要放入数据的空位置**。
|
||||
|
||||
4. Empty ascending 空递增堆栈——EA 堆栈首部是低地址,堆栈向高地址增长。栈指针**总是指向下一个将要放入数据的空位置**。
|
||||
|
||||
**A 和D 定义堆栈是递增还是递减,如果递增,STM将向上,LDM向下,如果递减,则相反。**
|
||||
|
||||
**所以,LDMFD和STMFD是成对使用,因为堆栈方式和出栈方式要是相同的**
|
||||
|
||||
|
||||
|
||||
### 六、ARM 伪指令
|
||||
|
||||
| 伪指令助记符 | 说明 | 操作 |
|
||||
| ----------------------------------- | ------------------------ | ------------------------------------------ |
|
||||
| ADR{cond} register,exper | 小范围的地址读取伪指令 | register<-expr 指向的地址 |
|
||||
| ADRL {cond} register,exper | 中等范围的地址读取伪指令 | register<-expr 指向的地址 |
|
||||
| LDR{cond} register,=expr/label_expr | 大范围的地址读取伪指令 | register<-expr/label-expr 指定 的数据/地址 |
|
||||
| NOP | 空操作伪指令 | |
|
||||
|
||||
|
||||
|
||||
### 七、简单介绍数据定义伪指令
|
||||
|
||||
#### 1、DCB 分配一段字节的内存单元
|
||||
|
||||
**{label} DCB expr{,expr}{,expr}…**
|
||||
|
||||
```
|
||||
A
|
||||
DCB 0x11,0x22,0x33,0x44
|
||||
DCB 0x55,0x66,0x77,0x88
|
||||
DCB "Hello World"
|
||||
DCB "ABCDEFGHIJKLMN",0
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### 2 、DCW 和 DCWU 分配一段半字的内存单元
|
||||
|
||||
DCWU 需要半字对齐
|
||||
|
||||
```assembly
|
||||
B
|
||||
DCW 0x1122,0x3344,0x5566,0x7788
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#### 3、 DCD 和 DCDU 分配一段字内存单元
|
||||
|
||||
DCD 需要字对齐
|
||||
|
||||
```assembly
|
||||
C
|
||||
DCW 0x11223344,0x55667788,0x99aabbcc,0xddeeff00
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### 4、SPACE 分配一片连续的字节内存单元,并初始化为0
|
||||
|
||||
**{label} SPACE expr**
|
||||
|
||||
```assembly
|
||||
D
|
||||
Space 500 ;分配 500 字节空间,并初始化为0
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 八、汇编程序设计
|
||||
|
||||
#### 程序 1 、使用跳转完成函数功能
|
||||
|
||||
首先,我们先来一个简单的跳转指令:
|
||||
|
||||
```assembly
|
||||
... ;之前的一些操作
|
||||
BL ADD_FUNCTION ;带连接的跳转,LR <- PC -4 ,PC <- ADD_FUNCTION
|
||||
... ;完成ADD_FUNCTION 这个函数的操作
|
||||
...
|
||||
ADD_FUNCTION
|
||||
... ;一些操作
|
||||
MOV PC,LR ;函数返回,相当于RET、Return,总之就是 PC <- LR
|
||||
```
|
||||
|
||||
|
||||
|
||||
下面这个例子是老师给的:
|
||||
|
||||
**设计一个函数,计算R0 和 R1 的值**
|
||||
``` assembly
|
||||
AREA Example1,CODE,READONLY
|
||||
ENTRY
|
||||
start
|
||||
LDR R0,=0X66
|
||||
LDR R1,=0X88
|
||||
BL ADD_FUNCTION ;带链接跳转
|
||||
B RETURN ;跳到结束位置
|
||||
ADD_FUNCTION
|
||||
ADD R0,R0,R1
|
||||
MOV PC,LR
|
||||
RETURN
|
||||
END
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### 程序 2 、计算数组第1项和第5项之和,并将结果保存在第9项中
|
||||
|
||||
```assembly
|
||||
AREA Example1,CODE,READONLY
|
||||
ENTRY
|
||||
start
|
||||
LDR R0,=ARRAY
|
||||
LDR R1,[R0]
|
||||
LDR R2,[R0,#16]
|
||||
ADD R1,R1,R2
|
||||
|
||||
STR R1,[R0,#32]
|
||||
|
||||
|
||||
ARRAY
|
||||
DCD 0X11,0X22,0X33,0X44
|
||||
DCD 0X55,0X66,0X77,0X88
|
||||
DCD 0X00,0X00,0X00,0X00
|
||||
END
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### 程序 3、编写一个分支程序段,如果R5中的值等于10,就把R5中的数据存入R1,否则就把R5中的数据分别存入寄存器R0和R1
|
||||
|
||||
```assembly
|
||||
AREA Example1,CODE,READONLY
|
||||
ENTRY
|
||||
start
|
||||
MOV R5,#9
|
||||
CMP R5,#10
|
||||
MOVNE R0,R5
|
||||
MOV R1,R5
|
||||
END
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### 程序 4、编写一个程序段,当R1中的数据大于R2中的数据时,将R2中的数据加10存入R1中,否则将R2中的数据加5存入R1中
|
||||
|
||||
```assembly
|
||||
AREA Example1,CODE,READONLY
|
||||
ENTRY
|
||||
start
|
||||
MOV R1,#10
|
||||
MOV R2,#5
|
||||
CMP R1,R2
|
||||
ADDHI R1,R2,#10
|
||||
ADDLS R1,R2,#5
|
||||
END
|
||||
```
|
||||
|
||||
还记得吗?
|
||||
|
||||
> 无符号一般会使用 **Lower 和 Higher**和 **Same**,有符号一般会使用 **Greater than** 和 **Less than** 和 **Equal**,
|
||||
>
|
||||
> 所以,**无符号的大于等于** HS = Higher + Same ,**无符号小于等于** LS = Lower + Same ,**无符号大于 HI** = Higher(前两个字母),**无符号小于** LO = Lower
|
||||
>
|
||||
> 同理的,**有符号大于等于** GE = Greater + Equal ,**有符号小于等于** LE = Less + Equal, **有符号大于**就是 GT = Greater + Than ,**有符号小于**就是 LT = Less + Than
|
||||
|
||||
|
||||
|
||||
#### 程序 5、循环,将 src 中的10个字节的数据,传送到 dst 开始的区域
|
||||
|
||||
```assembly
|
||||
AREA init,CODE,READONLY
|
||||
ENTRY
|
||||
start
|
||||
LDR R0,=src
|
||||
LDR R1,=dst
|
||||
MOV R2,#0
|
||||
|
||||
LOOP
|
||||
LDRB R3,[R0,R2]
|
||||
STRB R3,[R1,R2]
|
||||
|
||||
ADD R2,R2,#1
|
||||
CMP R2,#10
|
||||
BLO LOOP
|
||||
|
||||
src
|
||||
DCB "0123456789"
|
||||
dst
|
||||
DCB "aaaaaaaaaa"
|
||||
|
||||
END
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### 程序 6、循环,将src中的所有小写字母变成大写字母,其他的ASCII码不变
|
||||
|
||||
我们需要知道 ascii 码中,
|
||||
|
||||
A的十六进制是41,能够推出Z的十六进制是5A
|
||||
|
||||
a的十六进制是61,能够推出z的十六进制是7A
|
||||
|
||||
|
||||
|
||||
```assembly
|
||||
AREA init,CODE,READONLY
|
||||
ENTRY
|
||||
start
|
||||
LDR R0,=src
|
||||
MOV R1,#0
|
||||
|
||||
LOOP
|
||||
LDRB R2,[R0,R1]
|
||||
CMP R2,#0X61
|
||||
BLO NEXT
|
||||
CMP R2,#0X7A
|
||||
SUBLS R2,R2,#0X20
|
||||
STRBLS R2,[R0,R1]
|
||||
|
||||
NEXT
|
||||
ADD R1,R1,#1
|
||||
CMP R1,#10
|
||||
BNE LOOP
|
||||
|
||||
src
|
||||
DCB "AabCdEfghI"
|
||||
END
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### 程序 7、循环,将src中的所有大写字母变成小写字母,其他的ASCII码不变
|
||||
|
||||
和上一题同理
|
||||
|
||||
```assembly
|
||||
AREA init,CODE,READONLY
|
||||
ENTRY
|
||||
start
|
||||
LDR R0,=src
|
||||
MOV R1,#0
|
||||
|
||||
LOOP
|
||||
LDRB R2,[R0,R1]
|
||||
CMP R2,#0X41
|
||||
BLO NEXT
|
||||
CMP R2,#0X5A
|
||||
ADDLS R2,R2,#0X20
|
||||
STRBLS R2,[R0,R1]
|
||||
|
||||
NEXT
|
||||
ADD R1,R1,#1
|
||||
CMP R1,#10
|
||||
BNE LOOP
|
||||
|
||||
src
|
||||
DCB "AabCdEfghI"
|
||||
END
|
||||
```
|
||||
17
content/posts/birthday.md
Normal file
17
content/posts/birthday.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
id: e2c3eb0b-269a-403d-987f-da9e9de5f2ea
|
||||
slug: birthday
|
||||
title: 生日生日
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-12-10T15:03:49+08:00"
|
||||
created_at: "2022-12-10T15:03:49+08:00"
|
||||
updated_at: "2026-01-07T18:10:56+08:00"
|
||||
---
|
||||
|
||||
祝自己生日快乐!!!!
|
||||
40
content/posts/blog-transfer-complete.md
Normal file
40
content/posts/blog-transfer-complete.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
id: d45ebe08-3f81-474f-a284-93170d866f5a
|
||||
slug: blog-transfer-complete
|
||||
title: 博文转移完毕
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-02-24T00:56:03+08:00"
|
||||
created_at: "2023-02-24T00:56:03+08:00"
|
||||
updated_at: "2026-01-07T18:11:03+08:00"
|
||||
---
|
||||
|
||||
由于之前的博客存储在了 sqlite3 数据库里,显然是没办法直接导入 Hexo 所识别的格式的,所以用 python 写了
|
||||
一个小脚本,方便博文的转移。
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
conn = sqlite3.connect("Hsunr.db")
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('select * from post')
|
||||
values = cursor.fetchall()
|
||||
for item in values:
|
||||
file_name = "{}-{}.md".format(item[5],item[1].replace(' ','-'))
|
||||
dt_obj = datetime.strptime(item[0], '%Y%m%d%H%M%S')
|
||||
content = "---\\n" \\
|
||||
"title: {}\\n" \\
|
||||
"date: {}\\n" \\
|
||||
"tags:\\n" \\
|
||||
"categories: {}\\n" \\
|
||||
"---\\n"\\
|
||||
"{}".format(item[1],dt_obj,item[3],item[2])
|
||||
f = open(file_name,'w');
|
||||
f.write(content)
|
||||
```
|
||||
17
content/posts/busy.md
Normal file
17
content/posts/busy.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
id: 4bb7176d-2d79-42dc-8e52-cbf237fcff26
|
||||
slug: busy
|
||||
title: 忙
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-06-14T12:20:18+08:00"
|
||||
created_at: "2023-06-14T12:20:18+08:00"
|
||||
updated_at: "2026-01-07T18:11:37+08:00"
|
||||
---
|
||||
|
||||
最近事情真的很多,实训整得我莫名其妙地很累,也没啥时间写博文,况且静态博客的弊端就是无法随时地去写啦,最近想着要不要用 java 去搭建一个服务,上面用来写一些短的评论或者吐槽的话语,目前的初步想法是这样的。
|
||||
25
content/posts/clash-deleted-repository.md
Normal file
25
content/posts/clash-deleted-repository.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
id: 5301ab11-352c-4e08-9e8b-c0e163d5566d
|
||||
slug: clash-deleted-repository
|
||||
title: Clash 删库
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-11-03T07:38:47+08:00"
|
||||
created_at: "2023-11-03T07:38:47+08:00"
|
||||
updated_at: "2026-01-07T18:11:56+08:00"
|
||||
---
|
||||
|
||||
昨天 clash for windows 删库,今天(2023-11-03) clash 也删库了。前者的删库并没有太大的影响,因为它只是后者的 GUI 工具而已,但是后者的删库则有些严重了,这意味着clash 彻底失去了更新维护。clash 的开发者可是是为了暂时规避 cfw 的影响,当然也不排除他已经受到了政府的迫害,目前还没有更多的消息。
|
||||
|
||||
另一个比较常用的代理软件就是 V2Ray 了, 目前没有看出受到了影响,这取决于开发者是否处于国内,是否会受到政府监控的压力。
|
||||
|
||||
这里出现了一个比较悲伤观点:世界上并没有那么多对网络严格监管的国家,这意味着在自由的国家中,并没有如此多的开发者愿意去创造和提供高隐私和高安全性的网络代理工具,这意味着,这类软件的开发者大概率也是受到了互联网的封锁,意味着他们也大概率在独裁威权国家里生活。
|
||||
|
||||
我真的感到非常的伤心。
|
||||
|
||||
当神话故事中的人类看到带给他们火种的普罗米修斯被锁在高加索山之上,日日被巨鹰啄食的时候,我想他们也会很伤心吧。
|
||||
40
content/posts/debtap.md
Normal file
40
content/posts/debtap.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
id: 2d63bae5-7edc-4bd1-8332-0782bf2bda0a
|
||||
slug: debtap
|
||||
title: Debtap
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-03-26T15:54:44+08:00"
|
||||
created_at: "2023-03-26T15:54:44+08:00"
|
||||
updated_at: "2025-12-31T23:58:08+08:00"
|
||||
---
|
||||
|
||||
## Setup
|
||||
``` bash
|
||||
==> You must run at least once "debtap -u"
|
||||
with root privileges (preferably recently),
|
||||
before running this script
|
||||
|
||||
==> Syntax: debtap [option] package_filename
|
||||
|
||||
==> Run "debtap -h" for help
|
||||
```
|
||||
|
||||
### Update debtap source
|
||||
```bash
|
||||
$ debtap -u
|
||||
```
|
||||
|
||||
### Convert deb package
|
||||
```bash
|
||||
debtap xxx.deb
|
||||
```
|
||||
### Install
|
||||
```bash
|
||||
sudo pacman -U xxx.pkg
|
||||
```
|
||||
21
content/posts/december-project-1a-release.md
Normal file
21
content/posts/december-project-1a-release.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: 6b6e986a-3ca4-42a7-8318-380834cd5816
|
||||
slug: december-project-1a-release
|
||||
title: December 项目 1a 版本释出
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-10-10T16:20:00+08:00"
|
||||
created_at: "2022-10-10T16:20:00+08:00"
|
||||
updated_at: "2026-01-07T18:10:32+08:00"
|
||||
---
|
||||
|
||||
## 开发
|
||||
December 总体采用前后端分离技术。
|
||||
前端使用 React 、React-MU 和 基础前端构建语言,后端使用 FLask 和 Sqlite3 。
|
||||
|
||||
目前已初步开发完成,并且将代码规模控制在一定范围,后端部分代码也进行了不同程度地优化,这将是一个可长期维护的项目。
|
||||
118
content/posts/dwm-st-installation-patches.md
Normal file
118
content/posts/dwm-st-installation-patches.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
---
|
||||
id: 9e345b0f-802d-4083-a9c8-1636e98b8f27
|
||||
slug: dwm-st-installation-patches
|
||||
title: dwm 和 st 的安装及补丁
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-05-05T15:59:30+08:00"
|
||||
created_at: "2023-05-05T15:59:30+08:00"
|
||||
updated_at: "2026-01-07T18:11:27+08:00"
|
||||
---
|
||||
|
||||
## 前言
|
||||
|
||||
得益于近些日子在学校深入学习了很多关于 Linux 的知识,加之之前也多次重装过 Arch ,这些因素综合起来后,这一次的系统安装可以说颇为顺畅,之前因无法理解的地方而导致难以下手的操作,所赖知识见长,逐渐克服且熟练了起来。
|
||||
|
||||
本次为了更加深入地去学习 Linux ,所以放弃了像 Gnome 这样简单易用的桌面环境, 而是使用像 DWM 这样更简单的窗口管理器,虽然是自找麻烦,但我也乐在其中,我会在本文中简略地记录下折腾的过程。
|
||||
|
||||
## DWM 和 st 的安装
|
||||
|
||||
事实上安装 DWM 非常的简单,自需要从 suckless 官网下载源码文件即可。
|
||||
```bash
|
||||
|
||||
$ git clone git://git.suckless.org/dwm
|
||||
$ cd dwm
|
||||
$ make
|
||||
# sudo make install
|
||||
|
||||
```
|
||||
|
||||
编译安装后,我这里是通过 xrog 的 `.xinitrc` 从 tty 终端使用 `startx` 命令启动的:
|
||||
```bash
|
||||
exec dwm
|
||||
```
|
||||
|
||||
启动之后的第一眼就是简陋,目前还什么都干不了,因为我甚至还没安装终端,suckless 也提供了一个 xrog 下的终端实现,叫 `st` 。
|
||||
通过魔法键回到 tty (ps.当然也可以通过快捷键:`Alt` `+` `Shift` `+` `Q` 退出 dwm ),通过 tty 的终端去下载 st 源码。
|
||||
|
||||
同样也是通过源码安装:
|
||||
```bash
|
||||
$ git clone https://git.suckless.org/st
|
||||
$ cd st
|
||||
$ make
|
||||
# sudo make install
|
||||
```
|
||||
编译安装完后,在dwm便应该通过`Alt` `+` `Shift` `+` `Enter` 组合键启动 `st` 管终端。但是毫无疑问,也是十分简陋,甚至字体小到无法看清……
|
||||
|
||||
|
||||
不过问题不大,我学过 C ,源码文件是能看懂的,首先就是修改了字体大小,顺带一提我还通过 pacman 包管理下载了 [ttf-fira-code] 字体。
|
||||
|
||||
首先来修改一下 dwm 的字体和其大小:
|
||||
|
||||
```c
|
||||
# dwm > config.h
|
||||
|
||||
static const char *fonts[] = { "Fira Code:size=14" };
|
||||
static const char dmenufont[] = "Fira Code:size=14";
|
||||
|
||||
```
|
||||
保存后通过 `sudo make clean install` 命令安装即可
|
||||
|
||||
接着修改 st 终端的字体和大小:
|
||||
|
||||
```c
|
||||
# st > config.h
|
||||
|
||||
static char *font = "Fira Code:style=Bold:pixelsize=23:antialias=true:autohint=true";
|
||||
```
|
||||
|
||||
保存后通过 `sudo make clean install` 命令安装即可
|
||||
|
||||
这样修改完后,便好看很多了。
|
||||
|
||||
## 安装 dmenu
|
||||
|
||||
这个就是 suckless 的程序托盘?
|
||||
总之,安装后,可以快速打开程序,也是蛮好的,这个就没有使用源码安装了,不过听说它也有很多好玩之处,之后再研究吧,总之 dmenu 通过包管理器安装:
|
||||
|
||||
```bash
|
||||
$ sudo pacman -S dmenu
|
||||
```
|
||||
|
||||
## 给 dwm 打补丁
|
||||
Dwm 目前其实足够我使用了, 这里就打一个透明补丁: 「 [alpha.diff](https://dwm.suckless.org/patches/alpha/dwm-alpha-20201019-61bb8b2.diff
|
||||
) 」
|
||||
|
||||
```bash
|
||||
$ wget https://dwm.suckless.org/patches/alpha/dwm-alpha-20201019-61bb8b2.diff
|
||||
$ patch p1 < dwm-alpha-20201019-61bb8b2.diff
|
||||
|
||||
```
|
||||
通过 `patch` 命令 把补丁打进去即可,之前我都没怎么用过这个 patch 命令,用多了其实也还好,出现错误了,手动去修改源文件即可。
|
||||
|
||||
## 给 st 打补丁
|
||||
这里打了好几个补丁:
|
||||
- 「 [st-alpha-20220206-0.8.5.diff](https://st.suckless.org/patches/alpha/st-alpha-20220206-0.8.5.diff) 」 这是改变st透明度的补丁。
|
||||
- 「 [st-scrollback-20210507-4536f46.diff](https://st.suckless.org/patches/scrollback/st-scrollback-20210507-4536f46.diff) 」这是让 st 终端支持回滚(按键支持)
|
||||
- 「 [st-scrollback-mouse-20220127-2c5edf2.diff](https://st.suckless.org/patches/scrollback/st-scrollback-mouse-20220127-2c5edf2.diff) 」这是回滚的鼠标滚轮支持(ps.需要先安装scrollback 补丁)
|
||||
|
||||
也是一样地通过 `patch` 命令打进去,这里就不再赘述了。
|
||||
|
||||
## 安装 picom
|
||||
这是一个合成管理器,可以给窗口管理器带来需要效果,比如透明、阴影,上面的透明补丁也需要它的安装才能真正起作用。
|
||||
|
||||
```bash
|
||||
$ sudo pacman -S picom
|
||||
```
|
||||
|
||||
安装完后,编辑 ` ~/.xinitrc ` 文件,进程让它以 Daemon(后台) 的形式运行。
|
||||
```bash
|
||||
compton -b
|
||||
```
|
||||
|
||||
(未完待续)
|
||||
95
content/posts/film-photo-album-1.md
Normal file
95
content/posts/film-photo-album-1.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
id: ee96d658-3369-4f7d-b4cb-787a72d6f88a
|
||||
slug: film-photo-album-1
|
||||
title: 胶片图集 (一)
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-07T09:29:11+08:00"
|
||||
created_at: "2024-03-13T05:08:23+08:00"
|
||||
updated_at: "2026-01-07T09:29:11+08:00"
|
||||
---
|
||||
|
||||
许久没有写一些博文了,请原谅我,因为生活比较单调,加上最近有一些抑郁的情绪,目前仍然在调整当中。虽然生活千篇一律,但是偶尔也是有出去晒太阳的,也是有好好生活的。
|
||||
|
||||
我最近拍了很多的胶片,于是打算放到这里来,让大家看看~
|
||||
|
||||
### 正文
|
||||
|
||||
这是一个用半幅胶片机拍摄的,黑白的负片,加上半格画幅带来的画质损失,使得这幅照片拥有一种恰到好处的感觉。拍摄的时间是哈尔滨的冬季,可以看到路上都是雪,周围的树木也光秃秃的,黑白拍摄树枝有时候会很杂乱,但是这幅照片倒还好。
|
||||
|
||||
这张照片是我的第一卷胶片,当时买了 Canon Demi 初代半格相机,寻思着玩一玩吧,结果一发不可收拾!拿着这台相机在学校到处拍摄,从那时候开始,我彻底爱上了胶片。胶片是一种延迟的体验,一卷胶卷大约能拍32张,而半格则更多了(画幅相对于135mm裁切了一半),也就是需要拍满72张才能取下胶卷,而且拍完无法立即查看,需要送给冲洗店老板冲扫,这又往往还需等待一个礼拜的时间。
|
||||
|
||||

|
||||
|
||||
通过胶片,我记录下的照片常常成为我生活的缩影,它介于严肃与不严肃之间,而其本身的质感往往让人感到温暖和怀旧,我后面又陆陆续续买了 佳能 QL17 旁轴、佳能 kiss 1单反、佳能 eos55 单反等,就我而言,单反相对更加容易掌控,其功能与镜头的选择也更加丰富。
|
||||
|
||||
与现代相机一样,胶片相机也需要在拍摄时选择光圈,需要权衡景深的大小,而慢速快门则要考虑画面是否会受到抖动的影响,不同的是一旦按下快门则无法修改,一张底片就此定格。
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
下面这张图的猫猫很可爱的,当时我走过去的时候它主动地帖帖我了,或许是想要我给他挠痒,围着我走了一圈,然后贴在我的鞋边!我怎么能抗拒得了这样的诱惑呢!我充分地挠了挠它的猫猫头后,它也享受地走开了,之后我便去其他地方了,再回来就看到它在边躲太阳边休息。
|
||||
|
||||
于是我立马掏出了相机,幸好我有 28-105 的变焦镜头,不用靠得特别近也能拍摄。虽说猫猫看起来很可爱,但它们可听不懂我们人类的话,靠得太近了,惹恼了它可就不愉快了,索性就在不远处保持着距离拍摄。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
小灯笼和小狮子
|
||||
|
||||
虚化带来的好处就是主体很清晰,消除背景细节,可以营造出更为干净、简洁的画面
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
这张是在龙母庙拍的,主要是测试一下我新买的镜头 40mm 2.8 光圈全开的虚化效果 。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
这张是半格机拍的, ILFORD PAN 100 好歹也是 100 感光度的胶卷了,但是拍出来还是出现了很明显的颗粒。难道半格相机还得使用 ISO 50 的 5203 拍摄会比较好吗?但是这又出现了一个问题,demi是估焦的,拍摄近物时,往往需要缩小光圈以达到超焦距的效果,然而使用 50 ISO后,便不太能使用小光圈了,因为这可能会导致画面抖动或者欠曝,真是难以抉择呢。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
接下来的这三张,是我家城市的骑楼城,梧州的河东许多的老建筑特别有历史的底蕴,我很喜欢在这里拍摄黑白的照片,当然彩色的照片也非常好看,有一种不一样的质感。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
这张图我觉得很棒噢,当时很顺利地抓拍了,其实等车再开过去一些些会好很多呢。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
最后想说的是,我最近有在尝试自己冲洗黑白胶卷噢,听起来是挺复杂的,但是实际做起来非常的简单,而且特别有成就感,但就不在这篇文章叙述多言了,之后会再写一篇的。
|
||||
|
||||
(完)
|
||||
149
content/posts/flask-getting-started.md
Normal file
149
content/posts/flask-getting-started.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
id: 613ca940-0c67-4ad5-a571-83f582c480d8
|
||||
slug: flask-getting-started
|
||||
title: 工程 [2023_3_25] 附录文件「一」 - Flask 起步
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-03-26T15:21:24+08:00"
|
||||
created_at: "2023-03-26T15:21:24+08:00"
|
||||
updated_at: "2026-01-07T18:11:11+08:00"
|
||||
---
|
||||
|
||||
## 前言
|
||||
因为前段时间实验室老师要求我完成一个项目,是有关学校教务系统的,要求完成一个微信小程序。
|
||||
|
||||
目前处于实验阶段,老师也没有给我需求文档,所以目前的情况就是做些东西练练手。这里记录一下,之后正式工作了能快速完成基本的构建。
|
||||
|
||||
> 以下内容转载自 [Flask官方文档](https://flask.palletsprojects.com/en/2.2.x/quickstart/)
|
||||
## Flask Installation
|
||||
### Create an environment
|
||||
Create a project folder and a venv folder within:
|
||||
```bash
|
||||
$ mkdir myproject
|
||||
$ cd myproject
|
||||
$ python3 -m venv venv
|
||||
```
|
||||
### Activate the environment
|
||||
|
||||
Before you work on your project, activate the corresponding environment:
|
||||
```bash
|
||||
$ . venv/bin/activate
|
||||
```
|
||||
|
||||
### Install Flask
|
||||
|
||||
Within the activated environment, use the following command to install Flask:
|
||||
```bash
|
||||
$ pip install Flask
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
A minimal Flask application looks something like this:
|
||||
|
||||
```python
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def hello_world():
|
||||
return "<p>Hello, World!</p>"
|
||||
```
|
||||
|
||||
So what did that code do?
|
||||
|
||||
1. First we imported the ```Flask``` class. An instance of this class will be our WSGI application.
|
||||
|
||||
2. Next we create an instance of this class. The first argument is the name of the application’s module or package. ```__name__``` is a convenient shortcut for this that is appropriate for most cases. This is needed so that Flask knows where to look for resources such as templates and static files.
|
||||
|
||||
3. We then use the route() decorator to tell Flask what URL should trigger our function.
|
||||
|
||||
4. The function returns the message we want to display in the user’s browser. The default content type is HTML, so HTML in the string will be rendered by the browser.
|
||||
|
||||
Save it as ```hello.py``` or something similar. Make sure to not call your application ```flask.py``` because this would conflict with Flask itself.
|
||||
|
||||
To run the application, use the ```flask``` command or ```python -m flask```. You need to tell the Flask where your application is with the ```--app``` option.
|
||||
|
||||
```python
|
||||
$ flask --app hello run
|
||||
* Serving Flask app 'hello'
|
||||
* Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
### Externally Visible Server
|
||||
If you run the server you will notice that the server is only accessible from your own computer, not from any other in the network. This is the default because in debugging mode a user of the application can execute arbitrary Python code on your computer.
|
||||
|
||||
If you have the debugger disabled or trust the users on your network, you can make the server publicly available simply by adding ```--host=0.0.0.0``` to the command line:
|
||||
|
||||
```python
|
||||
$ flask run --host=0.0.0.0
|
||||
```
|
||||
|
||||
This tells your operating system to listen on all public IPs.
|
||||
|
||||
### Debug
|
||||
To enable debug mode, use the --debug option.
|
||||
```python
|
||||
$ flask --app hello run --debug
|
||||
* Serving Flask app 'hello'
|
||||
* Debug mode: on
|
||||
* Running on http://127.0.0.1:5000 (Press CTRL+C to quit)
|
||||
* Restarting with stat
|
||||
* Debugger is active!
|
||||
* Debugger PIN: nnn-nnn-nnn
|
||||
```
|
||||
|
||||
### Routing
|
||||
Modern web applications use meaningful URLs to help users. Users are more likely to like a page and come back if the page uses a meaningful URL they can remember and use to directly visit a page.
|
||||
|
||||
Use the ```route()``` decorator to bind a function to a URL.
|
||||
```python
|
||||
@app.route('/')
|
||||
def index():
|
||||
return 'Index Page'
|
||||
|
||||
@app.route('/hello')
|
||||
def hello():
|
||||
return 'Hello, World'
|
||||
```
|
||||
|
||||
### HTTP Methods
|
||||
Web applications use different HTTP methods when accessing URLs. You should familiarize yourself with the HTTP methods as you work with Flask. By default, a route only answers to ```GET``` requests. You can use the ```methods``` argument of the route() decorator to handle different HTTP methods.
|
||||
|
||||
```python
|
||||
from flask import request
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
return do_the_login()
|
||||
else:
|
||||
return show_the_login_form()
|
||||
```
|
||||
|
||||
### The Request Object
|
||||
The request object is documented in the API section and we will not cover it here in detail (see Request). Here is a broad overview of some of the most common operations. First of all you have to import it from the ```flask``` module:
|
||||
```python
|
||||
from flask import request
|
||||
```
|
||||
The current request method is available by using the method attribute. To access form data (data transmitted in a ```POST``` or ```PUT``` request) you can use the form attribute. Here is a full example of the two attributes mentioned above:
|
||||
```python
|
||||
@app.route('/login', methods=['POST', 'GET'])
|
||||
def login():
|
||||
error = None
|
||||
if request.method == 'POST':
|
||||
if valid_login(request.form['username'],
|
||||
request.form['password']):
|
||||
return log_the_user_in(request.form['username'])
|
||||
else:
|
||||
error = 'Invalid username/password'
|
||||
# the code below is executed if the request method
|
||||
# was GET or the credentials were invalid
|
||||
return render_template('login.html', error=error)
|
||||
```
|
||||
187
content/posts/flask-sqlalchemy-appendix-2.md
Normal file
187
content/posts/flask-sqlalchemy-appendix-2.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
---
|
||||
id: 194737d7-8f24-4f43-8303-f7837862a6ed
|
||||
slug: flask-sqlalchemy-appendix-2
|
||||
title: 工程 [2023_3_25] 附录文件「二」 - Flask SQLAlchemy
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-03-26T16:19:51+08:00"
|
||||
created_at: "2023-03-26T16:19:51+08:00"
|
||||
updated_at: "2026-01-07T18:11:15+08:00"
|
||||
---
|
||||
|
||||
## 前言
|
||||
本文主要介绍 Flask SQLAlchemy 的具体使用。
|
||||
|
||||
如需要了解 Flask 入门文档可跳转:
|
||||
- [ 附录文件「一」 ](/2023/03/26/工程-2023-3-25-附录文件「一」-Flask-起步/)
|
||||
- [ Flask官方文档 ](https://flask.palletsprojects.com/en/2.2.x/quickstart/)
|
||||
- [ Flask-Sqlalchemy 官方文档](https://flask-sqlalchemy.palletsprojects.com/en/2.x/queries/)
|
||||
## Setup
|
||||
### Install MySQL/MariaDB
|
||||
#### 1. Installation
|
||||
MariaDB is the default implementation of MySQL in Arch Linux, provided with the mariadb package.
|
||||
|
||||
**Install mariadb**, and run the following command **before starting** the ```mariadb.service```
|
||||
```bash
|
||||
# mariadb-install-db --user=mysql --basedir=/usr --datadir=/var/lib/mysql
|
||||
```
|
||||
|
||||
#### 2. Configuration
|
||||
Once you have started the MariaDB server and added a root account, you may want to change the default configuration.
|
||||
|
||||
To log in as root on the MariaDB server, use the following command:
|
||||
```bash
|
||||
# mariadb -u root -p
|
||||
```
|
||||
##### 2.1 Add user
|
||||
Creating a new user takes two steps: ```create the user```; ```grant privileges```. In the below example, the user monty with some_pass as password is being created, then granted full permissions to the database mydb:
|
||||
|
||||
```bash
|
||||
# mariadb -u root -p
|
||||
```
|
||||
```mysql
|
||||
MariaDB> CREATE USER 'monty'@'localhost' IDENTIFIED BY 'some_pass';
|
||||
MariaDB> GRANT ALL PRIVILEGES ON mydb.* TO 'monty'@'localhost';
|
||||
MariaDB> quit
|
||||
```
|
||||
|
||||
### 安装 PyMySQL 和 Flask-SQLAlchemy
|
||||
使用python 提供 ```pip``` 包管理器安装 ```pymysql``` 和 ```Flask-SQLAlchemy```
|
||||
```bash
|
||||
$ pip install PyMySQL
|
||||
$ pip install flask-sqlalchemy
|
||||
```
|
||||
#### Connection URI Format
|
||||
For a complete list of connection URIs head over to the SQLAlchemy documentation under (Supported Databases). This here shows some common connection strings.
|
||||
|
||||
SQLAlchemy indicates the source of an Engine as a URI combined with optional keyword arguments to specify options for the Engine. The form of the URI is:
|
||||
```python
|
||||
dialect+driver://username:password@host:port/database
|
||||
```
|
||||
|
||||
MySQL:
|
||||
```python
|
||||
mysql://scott:tiger@localhost/mydatabase
|
||||
```
|
||||
|
||||
### Configuration Keys
|
||||
**SQLALCHEMY_DATABASE_URI**
|
||||
|
||||
The database URI that should be used for the connection. Examples:
|
||||
- sqlite:////tmp/test.db
|
||||
- mysql://username:password@server/db
|
||||
|
||||
### 创建数据库
|
||||
安装完上边的套件后,就可以正式创建Mysql数据库了。
|
||||
#### 使用root用户创建数据库
|
||||
```bash
|
||||
sudo mariadb -u root -p
|
||||
```
|
||||
进入mariadb后,使用如下命令创建数据库:
|
||||
```sql
|
||||
MariaDB [(none)]> create database proj_20230325;
|
||||
Query OK, 1 row affected (0.000 sec)
|
||||
```
|
||||
|
||||
#### 给予权限
|
||||
之后给予用户 Yarnom 该数据库的所有权限:
|
||||
```sql
|
||||
MariaDB [(none)]> GRANT ALL PRIVILEGES ON proj_20230325.* TO 'yarnom'@'localhost';
|
||||
Query OK, 0 rows affected (0.009 sec)
|
||||
```
|
||||
### 安装 FLask-Migrate
|
||||
```bash
|
||||
$ pip install Flask-Migrate
|
||||
```
|
||||
#### 配置
|
||||
```python
|
||||
from flask_migrate import Migrate
|
||||
app = Flask(__name__)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = "mysql+pymysql://yarnom:root@localhost:3306/proj_20230325"
|
||||
db = SQLAlchemy(app)
|
||||
Migrate(app,db)
|
||||
```
|
||||
|
||||
#### 初始化
|
||||
```bash
|
||||
$ flask db init
|
||||
```
|
||||
|
||||
### Flask 使用Mysql数据库
|
||||
创建出相应的模板:
|
||||
|
||||
```python
|
||||
class Student(db.Model):
|
||||
id = db.Column('id', db.String(100),primary_key=True)
|
||||
name = db.Column('name',db.String(100))
|
||||
def __init__(self, id, name):
|
||||
self.id =id
|
||||
self.name = name
|
||||
```
|
||||
|
||||
使用如下命令更新数据库
|
||||
```bash
|
||||
$ flask db migrate -m "說明文字"
|
||||
$ flask db upgrade
|
||||
```
|
||||
### Flask-Sqlalchemy 使用
|
||||
> 以下内容转载自[flask-sqlalchemy 官方文档](https://flask-sqlalchemy.palletsprojects.com/en/2.x/queries/)
|
||||
#### Flask 添加数据
|
||||
```python
|
||||
me = User('admin', 'admin@example.com')
|
||||
db.session.add(me)
|
||||
db.session.commit()
|
||||
```
|
||||
#### Flask 查询数据
|
||||
So how do we get data back out of our database? For this purpose Flask-SQLAlchemy provides a query attribute on your Model class. When you access it you will get back a new query object over all records. You can then use methods like filter() to filter the records before you fire the select with all() or first(). If you want to go by primary key you can also use get().
|
||||
|
||||
The following queries assume following entries in the database:
|
||||
|id|username|email|
|
||||
|---|---|---|
|
||||
|1|admin|admin@example.com|
|
||||
|2|peter|peter@example.org|
|
||||
|3|guest|guest@example.com|
|
||||
|
||||
Retrieve a user by username:
|
||||
```bash
|
||||
>>> peter = User.query.filter_by(username='peter').first()
|
||||
>>> peter.id
|
||||
2
|
||||
>>> peter.email
|
||||
u'peter@example.org'
|
||||
```
|
||||
|
||||
Same as above but for a non existing username gives None:
|
||||
```bash
|
||||
>>> missing = User.query.filter_by(username='missing').first()
|
||||
>>> missing is None
|
||||
True
|
||||
```
|
||||
|
||||
Selecting a bunch of users by a more complex expression:
|
||||
```bash
|
||||
>>> User.query.filter(User.email.endswith('@example.com')).all()
|
||||
[<User u'admin'>, <User u'guest'>]
|
||||
```
|
||||
|
||||
Ordering users by something:
|
||||
```bash
|
||||
>>> User.query.order_by(User.username).all()
|
||||
[<User u'admin'>, <User u'guest'>, <User u'peter'>]
|
||||
```
|
||||
|
||||
Limiting users:
|
||||
```bash
|
||||
>>> User.query.limit(1).all()
|
||||
[<User u'admin'>]
|
||||
```
|
||||
Getting user by primary key:
|
||||
```bash
|
||||
>>> User.query.get(1)
|
||||
<User u'admin'>
|
||||
```
|
||||
170
content/posts/geographic-coordinate-conversion.md
Normal file
170
content/posts/geographic-coordinate-conversion.md
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
---
|
||||
id: b2fdfd13-695a-4665-8574-6309ee049188
|
||||
slug: geographic-coordinate-conversion
|
||||
title: 地理坐标转换
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-07T09:27:48+08:00"
|
||||
created_at: "2024-06-29T07:07:00+08:00"
|
||||
updated_at: "2026-01-07T09:27:48+08:00"
|
||||
---
|
||||
|
||||
近些日子的工作频繁接触到了大地测量中的各种知识,在编写程序的时候更是涉及各种地理坐标的转换,虽然没有深入去学习其核心的变化原理,但是简单地使用现存的公式配合程序进行计算还是可以做到的,以下是有关知识的记录。
|
||||
|
||||
|
||||
|
||||
### 1.名词解释
|
||||
|
||||
#### 1.1 LLA 坐标系
|
||||
|
||||
LLA 坐标是地理坐标系中的一种表示方法,LLA 代表经纬度和高度(Latitude, Longitude, and Altitude),它通常用于描述地球表面上的位置。
|
||||
|
||||
- **Latitude (纬度)**:表示从地球赤道向北或向南的角度,范围是 -90° 到 +90°。正值表示北纬,负值表示南纬。
|
||||
- **Longitude (经度)**:表示从本初子午线(通常是通过格林尼治的子午线)向东或向西的角度,范围是 -180° 到 +180°。正值表示东经,负值表示西经。
|
||||
- **Altitude (高度)**:表示相对于平均海平面的高度,可以是正值(高于海平面)或负值(低于海平面)。
|
||||
|
||||
LLA 坐标系也叫全球地理坐标系、大地坐标系、WGS-84坐标系。纬度和经度的数值可以以多种不同的单位或格式出现:
|
||||
|
||||
- 六十进制度:度、分、秒:40° 26′ 46“ N 79° 58′ 56” W
|
||||
- 度和十进制分:40° 26.767′ N 79° 58.933′ W
|
||||
- 十进制度: +40.446 -79.982
|
||||
|
||||
横纬竖经,在计算的过程中,主要也是将之转换为十进制来计算的,以下是计算公式:
|
||||
$$
|
||||
decimal = degress + \\frac{minutes}{60} + \\frac{seconds}{3600}
|
||||
$$
|
||||
|
||||
|
||||
关于 **WGS-84** ( World Geodetic System 1984,1984年世界大地测量系统),它是目前全球范围内使用最广泛的地理坐标系统和地球模型,由美国国防部制定和维护,主要用于全球定位系统(GPS)和各种地理信息系统(GIS)。
|
||||
|
||||
WGS-84 有几个关键常量用于定义参考椭球体的形状和尺寸,这些常量包括半长轴、半短轴、扁率和离心率等:
|
||||
|
||||
- **半长轴 (A)**:椭球体的赤道半径
|
||||
- 其值为 $ 6378137.0 $ 米。
|
||||
- **半短轴 (B)**:椭球体的极半径
|
||||
- 其计算公式为: $ B = A*(1-F) $
|
||||
- 其值为 $ 6356752.3142 $ 米
|
||||
- **扁率 (F)**:描述椭球体扁平程度的参数
|
||||
- 其计算公式为:$ F = \\frac{A-B}{A}$
|
||||
- 其值为:1/298.257223563
|
||||
- **第一离心率 (E)**:描述椭球体形状的一种参数,反映了椭球体的偏离程度
|
||||
- 其计算公式:$ E = \\sqrt{1 - (\\frac{B}{A}) ^2} $
|
||||
- 其值约为: 0.0818191908426
|
||||
- **第一离心率的平方 (E²)**:减少计算所用
|
||||
- 其中值约为: 0.00669437999014
|
||||
|
||||
|
||||
|
||||
#### 1.2 ECEF坐标系
|
||||
|
||||
ECEF(Earth-Centered, Earth-Fixed)坐标系是一种三维笛卡尔坐标系,用于表示地球上的位置。ECEF 坐标系也称为地心地固坐标系,它的原点位于地球质心,并且随着地球的自转而旋转。
|
||||
|
||||
- **X 轴**:指向穿过地球赤道与本初子午线(通过格林尼治的子午线)交点的方向。
|
||||
- **Y 轴**:指向穿过地球赤道与东经90度子午线交点的方向。
|
||||
- **Z 轴**:指向北极方向,与地球自转轴一致。
|
||||
|
||||
ECEF 坐标系提供了一个统一的三维坐标框架,可以精确地表示地球表面和近地空间的任何位置。
|
||||
|
||||
|
||||
|
||||
#### 1.3 ENU坐标系
|
||||
|
||||
ENU(East-North-Up)坐标系是一种局部笛卡尔坐标系,用于表示相对于某个参考点的三维位置。
|
||||
|
||||
- ENU 坐标系的原点通常位于地球表面的某个参考点,该点的地理坐标为 (Latitude, Longitude, Altitude)。
|
||||
- **E 轴(东向轴)**:指向地平线的东方。
|
||||
- **N 轴(北向轴)**:指向地平线的北方。
|
||||
- **U 轴(上向轴)**:垂直向上,指向天空。
|
||||
|
||||
|
||||
|
||||
### 2. 坐标系转换
|
||||
|
||||
#### 2.1 从 LLA 坐标到 ECEF 坐标
|
||||
|
||||
这里约定LLA的经度为 $\\phi$,纬度为 $\\lambda $,海拔为 $ h $, 选取 WGS-84 坐标系参数,$a$和$b$分别是是赤道半径(半长轴)和极半径(半短轴),$e^2 = 1 - \\frac{b^2}{a^2}$是偏心率的平方, $f=1-\\frac{b}{a} $ 是基准椭球体的极扁率。
|
||||
$$
|
||||
\\begin{align}
|
||||
X& = (N(\\phi) + h) cos \\phi cos\\lambda \\\\\\\\
|
||||
Y& = (N(\\phi) + h) cos \\phi sin\\lambda \\\\\\\\
|
||||
Z& = (\\frac{b^2}{a^2}N(\\phi) + h)sin\\phi \\\\\\\\
|
||||
&=((1-e^2)N(\\phi) +h)sin\\phi \\\\\\\\
|
||||
&=((1-f)^2N(\\phi)+h)sin\\phi
|
||||
\\end{align}
|
||||
$$
|
||||
其中
|
||||
$$
|
||||
\\begin{align}
|
||||
N(\\phi) = \\frac{a^2}{\\sqrt{a^2cos^2\\phi + b^2sin^2\\phi}} = \\frac{a}{\\sqrt{1-e^2sin^2\\phi}}
|
||||
\\end{align}
|
||||
$$
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#### 2.2 从 ECEF 到 LLA坐标
|
||||
|
||||
将 ECEF 坐标 (X, Y, Z) 转换为经纬度和高度 (Latitude, Longitude, Altitude) 需要迭代计算。
|
||||
$$
|
||||
\\begin{align}
|
||||
\\lambda = atan2(Y,X)
|
||||
\\end{align}
|
||||
$$
|
||||
|
||||
|
||||
其中,atan2 是反正切函数
|
||||
|
||||
$\\lambda$ 是唯一能直接算出的,其余的纬度和高度的转换所要涉及 N 的循环关系
|
||||
$$
|
||||
\\begin{align}
|
||||
\\frac{Z}{p}cot\\phi = 1 - \\frac{e^2N}{N+h} \\\\\\\\
|
||||
h = \\frac{p}{cos\\phi} -N
|
||||
\\end{align}
|
||||
$$
|
||||
其中,如公式$(6)$所示,N 的变化取决于 $\\phi$ 的值
|
||||
|
||||
纬度和高度需要迭代求解, 例如,从第一个猜测 h≈0 开始,然后更新 N。
|
||||
|
||||
其流程是这样的:
|
||||
|
||||
1. 猜测 h = 0,通过公式$(8) $ 可以推算 $cot\\phi = \\frac{(1-e^2)p}{Z} $ 求解出一个$\\phi_1$
|
||||
2. 将 $\\phi_1$ 带入公式 $(6)$,求解出 $N$
|
||||
3. 将 $N$ 带入公式$(9)$ 求解出 $h$
|
||||
4. 将 $h$ 带入公式$(8)$ 求解出一个新的 $\\phi_2$
|
||||
5. 如果 $\\phi_1$和$\\phi_2$足够接近,则说明迭代出了一个正确的值,如果不是,则需要回到第2步继续迭代。
|
||||
|
||||
|
||||
|
||||
#### 2.3 从 ECEF 到 ENU 坐标
|
||||
|
||||
ENU 坐标又称为站心坐标,6也就是局部坐标,这个坐标需要一个局部参考点。在实际例子中,这个参考点通常是雷达、基站这些地方。
|
||||
|
||||
如果基站位于 ${X_0,Y_0,Z_0}$,测站位于 ${X_1,Y_1,Z_1}$
|
||||
|
||||
$$
|
||||
\\begin{bmatrix}
|
||||
e\\\\\\\\
|
||||
n\\\\\\\\
|
||||
u
|
||||
\\end{bmatrix}=
|
||||
\\begin{bmatrix}
|
||||
-sin\\lambda_0& cos\\lambda_0 & 0\\\\\\\\
|
||||
-sin\\phi_0cos\\lambda_0& -sin\\phi_0sin\\lambda_0 &cos\\phi_0 \\\\\\\\
|
||||
cos\\phi_0cos\\lambda_0& cos\\phi_0sin\\lambda_0 & sin\\phi_0
|
||||
\\end{bmatrix}
|
||||
\\begin{bmatrix}
|
||||
X_1 -X_0\\\\\\\\
|
||||
Y_1-Y_0\\\\\\\\
|
||||
Z_1-Z_0
|
||||
\\end{bmatrix}
|
||||
$$
|
||||
|
||||
|
||||
|
||||
(完)
|
||||
68
content/posts/git-learning.md
Normal file
68
content/posts/git-learning.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
id: ea5fd351-1d43-467f-a1d2-1b3c2300083c
|
||||
slug: git-learning
|
||||
title: git 学习
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-01T04:14:49+08:00"
|
||||
created_at: "2022-10-14T21:06:00+08:00"
|
||||
updated_at: "2026-01-07T18:10:38+08:00"
|
||||
---
|
||||
|
||||
git 虽说也用了蛮久的了,但是其实来来去去也是一些基础操作,更深入的倒也没有怎么去学,趁着这段时间稍微有些时间,把以前的笔记整理到这里来,也方便之后来查看。
|
||||
|
||||
## git commit
|
||||
这个命令用于提交记录
|
||||
```git
|
||||
git commit
|
||||
```
|
||||
|
||||
## git branch newImage
|
||||
这个命令用于创建新的分支。
|
||||
|
||||
Git 的分支也非常轻量。它们只是简单地指向某个提交纪录 —— 仅此而已。所以许多 Git 爱好者传颂。
|
||||
|
||||
***早建分支!多用分支!***
|
||||
|
||||
这是因为即使创建再多的分支也不会造成储存或内存上的开销,并且按逻辑分解工作到不同的分支要比维护那些特别臃肿的分支简单多了。
|
||||
|
||||
在将分支和提交记录结合起来后,我们会看到两者如何协作。现在只要记住使用分支其实就相当于在说:“我想基于这个提交以及它所有的父提交进行新的工作。”
|
||||
### 创建分支
|
||||
```git
|
||||
git branch newImage
|
||||
```
|
||||
### 切换分支
|
||||
```git
|
||||
git checkout <name>
|
||||
```
|
||||
|
||||
### 融合分支
|
||||
```git
|
||||
git merge <name>
|
||||
```
|
||||
|
||||
## 重基
|
||||
```
|
||||
git rebase <name>
|
||||
```
|
||||
|
||||
## ^ 和 ~number
|
||||
```git
|
||||
可以通过 ^ 符号向上找到父节点
|
||||
~ 则是多个
|
||||
```
|
||||
|
||||
## Git Reset
|
||||
git reset 通过把分支记录回退几个提交记录来实现撤销改动。你可以将这想象成“改写历史”。git reset 向上移动分支,原来指向的提交记录就跟从来没有提交过一样。
|
||||
|
||||
## Git Revert
|
||||
虽然在你的本地分支中使用 git reset 很方便,但是这种“改写历史”的方法对大家一起使用的远程分支是无效的哦!
|
||||
|
||||
奇怪!在我们要撤销的提交记录后面居然多了一个新提交!这是因为新提交记录 C2' 引入了更改 —— 这些更改刚好是用来撤销 C2 这个提交的。也就是说 C2' 的状态与 C1 是相同的。
|
||||
|
||||
revert 之后就可以把你的更改推送到远程仓库与别人分享啦。
|
||||
29
content/posts/git-ssh-proxy-configuration.md
Normal file
29
content/posts/git-ssh-proxy-configuration.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
id: fc35948f-dec0-4c86-9482-005d19313e01
|
||||
slug: git-ssh-proxy-configuration
|
||||
title: 给 GIT 和 SSH 配置代理
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-11-10T20:38:27+08:00"
|
||||
created_at: "2022-11-10T20:38:27+08:00"
|
||||
updated_at: "2026-01-07T18:10:45+08:00"
|
||||
---
|
||||
|
||||
今天 push 不了项目,估计又是网络的问题,决定还是给git和ssh配置一下代理,不然太难受了。
|
||||
配置记录如下:
|
||||
1.使用socks5代理,我这边的端口是1089
|
||||
```shell
|
||||
git config --global http.https://github.com.proxy socks5://127.0.0.1:1089
|
||||
```
|
||||
|
||||
2.修改 ~/.ssh/config 文件
|
||||
```shell
|
||||
Host github.com
|
||||
User git
|
||||
ProxyCommand nc -v -x 127.0.0.1:1089 %h %p
|
||||
```
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
---
|
||||
id: "e66587e776a53f83789175fa89221a84"
|
||||
slug: "go-astro-personal-blog"
|
||||
title: "用 Go 和 Astro 构建个人博客"
|
||||
summary: "第一篇测试文章"
|
||||
status: "published"
|
||||
id: 40471f98-cf30-4e89-a499-ecfe4dbf36b1
|
||||
slug: go-astro-personal-blog
|
||||
title: 用 Go 和 Astro 构建个人博客
|
||||
summary: 第一篇测试文章
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: "manual"
|
||||
version: 4
|
||||
slug_source: manual
|
||||
slug_locked: false
|
||||
published_at: "2026-05-28T11:42:01+08:00"
|
||||
created_at: "2026-05-28T11:42:01+08:00"
|
||||
updated_at: "2026-05-28T11:42:01+08:00"
|
||||
updated_at: "2026-06-01T10:23:37+08:00"
|
||||
---
|
||||
|
||||
这里开始写正文。
|
||||

|
||||
|
|
|
|||
38
content/posts/guangzhou-weather.md
Normal file
38
content/posts/guangzhou-weather.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
id: 609ff9b8-944e-4cc8-aea9-0b32c8a27c8c
|
||||
slug: guangzhou-weather
|
||||
title: 广州的天气
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-07T09:27:34+08:00"
|
||||
created_at: "2024-08-26T08:54:42+08:00"
|
||||
updated_at: "2026-01-07T09:27:34+08:00"
|
||||
---
|
||||
|
||||
六月份的时候在广州谋求了一份工作,公司不是很大,但氛围还挺好的,就是有些在学校实验室的感觉,幸得除我之外还有另一位实习同事,他是研究生毕业的,所学的是测绘,公司的主营业务也正是这一方向,而我则是以实习软件工程师的身份入职。
|
||||
|
||||
入职的时候也正是暑假开始,以往的暑假我都在家里晃晃度日,从小学到大学毕业,十多年的时间,眨眼就要工作了,时间就像是加速了一样,我现在仍能回想起小学的时候和小区里的好朋友一起玩泥巴过家家的场景,如今则真的要像一个成年人那样去自己生活了,即感慨又缅怀。说起家,我的恋家情绪其实并不严重,因为大学是从广西到哈尔滨上学,离家远,所以平时的短假是不够时间回家的,四年里也习惯了这种离家的感觉,加之现在有视频聊天,怎么样都不会太过恋家的。
|
||||
|
||||
入职的时候在广州这边的城中村租了一个小单间,房价 900 多一个月,但主要是水电好贵(水 5元/吨 · 电 1.5元/度), 以后有钱要狠狠搬家,现在做饭都很困难,因为没有厨房,可惜我的寒暑假磨练的厨艺了,并且今日去外面吃了青椒炒肉,居然和我水平一样,离谱的是要 19 块,当真是不便宜呢。
|
||||
|
||||
---
|
||||
|
||||
前段时间广州受到了季风气候的影响,下了一个星期的雨,感觉房间和衣服都霉臭霉臭的,并且阴天笼罩着使得每个人感觉都很不开心,那段时间也是我工作极为不顺的日子,但索性也都撑过来了,但还是有些后怕的,当然并非是工作方面的,而是生活!比如我的内裤和袜子在那段时间里就几乎就没有干过,要不是我带来广州的内裤和袜子多,还真的连门都出不了,所有感觉买一个烘干机还是很有必要的,回南天我想它必能发挥无比大的用处。所以也在想要不要买些一次性袜子和内裤作为应急之物,省得下雨回南时湿漉漉的,发霉到了极致,长小蘑菇可就不雅了。
|
||||
|
||||
说起应急之物,储备些药物似乎很有必要呢。
|
||||
### 药物
|
||||
#### 1. 外伤
|
||||
小型的擦伤创可贴+碘伏似乎就能搞定,更严重的伤口想必还是得去看医生,所以买一盒创可贴、棉签、碘伏
|
||||
|
||||
#### 2. 退烧止疼药
|
||||
毕竟是从新冠时代过来的,发烧止疼用「对乙酰氨基酚」和「布洛芬」我想就足够了,另外加一只体温计。
|
||||
|
||||
#### 3. 病毒性感冒
|
||||
这个似乎无药可治,还是以预防为主吧,前段时间还听说新冠又在广州蔓延,早上挤地铁时还看到有人戴 N95 口罩呢,事情的真假不得而知,总之戴口罩还是没有错的,避免病毒传播。所以,买些口罩在地铁的时候戴戴。
|
||||
|
||||
感冒按照以往的经验,不发烧即意味着一个星期便好,只不过中间很难受,发烧则可以尝试一下退烧药,若是反复烧则去医院抽血检查一下,想必是病毒太猛了,抽血针对性杀掉它们。
|
||||
19
content/posts/happy-new-year.md
Normal file
19
content/posts/happy-new-year.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: 8faf8227-2f08-4b33-9b4c-9b5f4bb1b064
|
||||
slug: happy-new-year
|
||||
title: 新年快乐
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2024-02-09T12:27:13+08:00"
|
||||
created_at: "2024-02-09T12:27:13+08:00"
|
||||
updated_at: "2026-01-07T18:11:58+08:00"
|
||||
---
|
||||
|
||||
新的一年又到啦,马上就毕业了!大学四年飞快地度过。
|
||||
|
||||
`rustic-page` 项目很久没有提交新的代码了,过段时间会提交一些新的功能代码,这个项目我想着尽量不去反复地重构。
|
||||
328
content/posts/hello-orangepi-zero3.md
Normal file
328
content/posts/hello-orangepi-zero3.md
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
---
|
||||
id: 09c5a7e9-e666-4c5a-ae89-2495eb2df4bd
|
||||
slug: hello-orangepi-zero3
|
||||
title: 你好,OrangePi Zero3!
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-08-17T11:48:44+08:00"
|
||||
created_at: "2023-08-17T11:48:44+08:00"
|
||||
updated_at: "2026-01-07T18:11:45+08:00"
|
||||
---
|
||||
|
||||
本篇文章将详细地写下我配置这块单板计算机的全过程,之后若是有新的自托管服务也会在本文记录下来。
|
||||
|
||||
## 购买
|
||||
|
||||
自从接触 Linux 之后渴望得到一个树莓派了,奈何因为疫情导致了全球的芯片供应紧张,树莓派的价格水涨船高,价格已经远远超过了它的价值,更重要的是超过了我的预算,即使是二手的价格也非常不值当。总之,出于囊中羞涩的考虑,我最终选择了这块国产的派 —— **OrangePi Zero3.**
|
||||
|
||||

|
||||
|
||||
我购买的是内存为 1.5G 的版本价格为一百出头,刚拿到快递的时候我就疑惑了, 为何如此之小!开箱后更是震惊,它甚至没有我的巴掌大呢!虽然但是,这还是很精致的,而且对于这样的设备而言,小其实是更好的,更方便搭载在其他的设备上。
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
除开这块板子,我还另外买了:
|
||||
|
||||
- 亚克力的外壳
|
||||
- 风扇+散热片
|
||||
- USB to TTL 用于串口调试
|
||||
- 64G 闪迪 tf 卡
|
||||
|
||||
|
||||
|
||||
关于系统镜像方面,我还是选择了熟悉的 debian 发行版,毕竟我的 vps 用的也是这个,这块单板计算机我是拿来内网穿透的 [官方镜像](http://www.orangepi.cn/html/hardWare/computerAndMicrocontrollers/service-and-support/Orange-Pi-Zero-3.html)
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
## 系统配置
|
||||
在向 tf 卡刷完系统后,就可以通过串口进行连接了。
|
||||
|
||||
|
||||
|
||||
### 自动连接热点 WiFi
|
||||
|
||||
使用串口多少是不方便的,所以先来配置一下网络,让其他机器可以通过 ssh 访问这块板子。
|
||||
|
||||
1. 使用`nmcli dev wifi`查看当前扫描到的 wifi 列表。
|
||||
|
||||
2. 使用 `nmcli` 连接到热点并输入密码:
|
||||
|
||||
```bash
|
||||
sudo nmcli dev wifi connect YourHotspotName password YourPassword
|
||||
```
|
||||
|
||||
3. 将连接的配置设置为自动连接:
|
||||
|
||||
```bash
|
||||
sudo nmcli connection modify "YourHotspotName" connection.autoconnect yes
|
||||
```
|
||||
|
||||
将 `YourHotspotName` 替换为你的热点名称,`YourPassword` 替换为密码。
|
||||
|
||||
完成后通过 `ip addr ` 即可看到 ip 地址,然后就可以通过 ssh 正常访问板子了
|
||||
|
||||
|
||||
|
||||
### 添加一个新用户
|
||||
|
||||
```
|
||||
useradd -m -G additional_groups -s login_shell username
|
||||
```
|
||||
|
||||
```bash
|
||||
passwd username
|
||||
```
|
||||
|
||||
然后去修改` /etc/sudoers` 文件
|
||||
|
||||
|
||||
|
||||
## FRP 内网穿透
|
||||
|
||||
在这个 [releases](https://github.com/fatedier/frp/releases) 页面中,找到对应架构的压缩包,可以使用 `uname -a ` 查看到。
|
||||
|
||||
- 我的 VPS 是 X86_64 那么就选择 [amd64.tar.gz](https://github.com/fatedier/frp/releases/download/v0.51.3/frp_0.51.3_linux_amd64.tar.gz) 的包
|
||||
- 香橙派是 arm 架构,那么就选择 [arm64.tar.gz ](https://github.com/fatedier/frp/releases/download/v0.51.3/frp_0.51.3_linux_arm64.tar.gz)的包
|
||||
|
||||
通过 `wget` 下载
|
||||
|
||||
```bash
|
||||
wget https://github.com/fatedier/frp/releases/download/v0.51.3/frp_0.51.3_linux_arm64.tar.gz
|
||||
```
|
||||
|
||||
通过 `tar` 解压
|
||||
|
||||
```bash
|
||||
tar xvf frp_0.51.3_linux_arm64.tar.gz
|
||||
```
|
||||
|
||||
- **frpc**: 这是 FRP 客户端的可执行文件。FRP 客户端用于连接到 FRP 服务器并将内部网络服务暴露给外部访问。
|
||||
- **frps**: 这是 FRP 服务器的可执行文件。FRP 服务器用于接收来自客户端的连接,并将这些连接转发到内部的网络服务。
|
||||
- **frpc.ini**: 这是 FRP 客户端的配置文件,用于指定客户端的连接设置、代理规则等。
|
||||
- **frps.ini**: 这是 FRP 服务器的配置文件,用于指定服务器的监听端口、代理规则等。
|
||||
- **frpc_full.ini**: 这是 FRP 客户端的完整配置文件,可能包含更多详细的设置。
|
||||
- **frps_full.ini**: 这是 FRP 服务器的完整配置文件,可能包含更多详细的设置。
|
||||
|
||||
|
||||
|
||||
### 服务器配置
|
||||
|
||||
下载完对应架构的文件后,即可开始配置,只需要关心 `frps` 和 `frps.ini` 文件即可,前者是FRP 服务器的可执行文件,后者是这是 FRP 服务器的配置文件。
|
||||
|
||||
打开 `frps.ini`进行配置,配置文件的内容如下:
|
||||
|
||||
```ini
|
||||
[common]
|
||||
bind_port = 7000
|
||||
token = 1234567890
|
||||
```
|
||||
|
||||
客户端和服务器的配置文件中都需要设置相同的 Token 才能正确进行连接和通信。
|
||||
|
||||
为了保障安全性,你应该选择一个强密码的 Token,并确保不会轻易泄露给他人。如果你怀疑 Token 可能已经暴露,应当立即生成一个新的 Token 并更新配置文件。
|
||||
|
||||
完成后,运行 `./frpc -c ./frpc.ini`
|
||||
|
||||
若无问题,则说明配置成功。
|
||||
|
||||
接下来,配置一下 systemd 服务,让上面这条运行命令能开机自动启动,这非常实用:
|
||||
|
||||
但是在此之前,我们需要先将 `frps` 和 `frps.ini` 文件放到合适的地方:
|
||||
|
||||
```
|
||||
sudo cp frps /usr/local/bin/
|
||||
sudo mkdir /usr/local/etc/frp
|
||||
sudo cp frps.ini /usr/local/etc/frp/
|
||||
```
|
||||
|
||||
|
||||
|
||||
首先,创建一个名为`frps.service`的文件,该文件将存储有关你的自启动服务的信息。通常,这些文件存放在`/etc/systemd/system/`目录下。
|
||||
|
||||
```bash
|
||||
sudo vim /etc/systemd/system/frps.service
|
||||
```
|
||||
|
||||
```
|
||||
[Unit]
|
||||
Description=frps
|
||||
After=network.target
|
||||
[Service]
|
||||
TimeoutStartSec=30
|
||||
WorkingDirectory=/usr/local/bin/
|
||||
ExecStart=/usr/local/bin/frps -c /usr/local/etc/frp/frps.ini
|
||||
Restart=on-failure
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
|
||||
|
||||
保存后,执行:
|
||||
|
||||
```
|
||||
sudo systemctl enable frps
|
||||
sudo systemctl start frps
|
||||
sudo systemctl status frps
|
||||
```
|
||||
|
||||
若无问题,则可进入客户端的配置。
|
||||
|
||||
下面是一些常用的命令:
|
||||
|
||||
- 启动服务:`sudo systemctl start frps`
|
||||
- 停止服务:`sudo systemctl stop frps`
|
||||
- 重启服务:`sudo systemctl restart frps`
|
||||
- 查看服务状态:`sudo systemctl status frps`
|
||||
- 关闭自启动:`sudo systemctl disable frps`
|
||||
|
||||
### 客户端配置
|
||||
|
||||
下载完对应架构的文件后,即可开始配置,只需要关心 `frpc` 和 `frpc.ini` 文件即可,前者是FRP 客户端的可执行文件,后者是这是 FRP 客户端的配置文件。
|
||||
|
||||
**假设:**
|
||||
|
||||
**服务器的ip地址是 1.1.1.1, 服务端frp正在监听的端口是7000, token是1234567890**
|
||||
|
||||
**客户端目前正在运行一个项目叫 bitwarden ,它正在本地 0.0.0.0 地址上监听 1111 端口**
|
||||
|
||||
|
||||
|
||||
打开 `frpc.ini`进行配置,配置文件的内容如下:
|
||||
|
||||
```
|
||||
[common]
|
||||
server_addr = 1.1.1.1
|
||||
server_port = 7000
|
||||
token = 1234567890
|
||||
|
||||
[bitwarden]
|
||||
type = tcp
|
||||
local_ip = 0.0.0.0
|
||||
local_port = 1111
|
||||
remote_port = 8111
|
||||
```
|
||||
|
||||
|
||||
|
||||
值得注意的是,配置文件中的 [bitwarden] 可以替换成任何想要的名字
|
||||
|
||||
为了方便,我们同样设置 systemd 服务
|
||||
|
||||
但是在此之前,我们需要先将 `frpc` 和 `frpc.ini` 文件放到合适的地方:
|
||||
|
||||
```
|
||||
sudo cp frpc /usr/local/bin/
|
||||
sudo mkdir /usr/local/etc/frp
|
||||
sudo cp frpc.ini /usr/local/etc/frp/
|
||||
```
|
||||
|
||||
|
||||
|
||||
首先,创建一个名为`frpc.service`的文件,该文件将存储有关你的自启动服务的信息。通常,这些文件存放在`/etc/systemd/system/`目录下。
|
||||
|
||||
```bash
|
||||
sudo vim /etc/systemd/system/frpc.service
|
||||
```
|
||||
|
||||
```
|
||||
[Unit]
|
||||
Description=frpc
|
||||
After=network.target
|
||||
[Service]
|
||||
TimeoutStartSec=30
|
||||
WorkingDirectory=/usr/local/bin/
|
||||
ExecStart=/usr/local/bin/frpc -c /usr/local/etc/frp/frpc.ini
|
||||
Restart=on-failure
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
|
||||
|
||||
保存后,执行:
|
||||
|
||||
```
|
||||
sudo systemctl enable frpc
|
||||
sudo systemctl start frpc
|
||||
sudo systemctl status frpc
|
||||
```
|
||||
|
||||
若无问题的话,则说明没有问题!
|
||||
|
||||
|
||||
|
||||
### 原理
|
||||
|
||||
Frp(Fast Reverse Proxy)是一个用于内网穿透的工具,它允许你在公网上访问位于内网的计算机服务。它的原理是通过在公网服务器上部署 frps(Frp 服务器端)和在内网机器上部署 frpc(Frp 客户端),通过公网服务器作为中转,将外部请求转发到内网机器上。
|
||||
|
||||
- 在公网服务器上部署 frps:公网服务器上运行的 frps 监听一个指定的端口,等待来自 frpc 的连接。
|
||||
- 在内网机器上部署 frpc:内网机器上的 frpc 将建立到 frps 的连接,并注册自己的服务信息。frpc 和 frps 之间建立的连接可以被用于穿透数据。
|
||||
- 数据传输:当公网客户端请求连接到公网服务器的指定端口时,frps 会将这个请求转发给已经连接的 frpc 客户端,然后 frpc 客户端再将请求转发到内网机器的指定服务上。
|
||||
|
||||
|
||||
|
||||
在我上面的配置中,其转换的意思就是:
|
||||
|
||||
frps 监听在公网服务器的 7000 端口,frpc 连接到公网服务器的 7000 端口,并注册自己的服务信息。frpc 客户端配置了一个类型为 TCP 的服务,将本地的 bitwarden 服务映射到公网服务器的 8111 端口上。
|
||||
|
||||
若是请求 https://1.1.1.1:8111 那么这个请求就转发给客户端的 http://0.0.0.0:1111
|
||||
|
||||
|
||||
|
||||
### Nginx 配置
|
||||
|
||||
我在服务器中是使用 Nginx 来进行反向代理的,若你的服务器不是,则可以跳过此内容。**若你完全不了解 nginx 建议先去学习了之后再回来进行配置,因为此处我写得很简略,可能会对你产生误导。**
|
||||
|
||||
我的 Nginx 代理了80端口,所有的请求都是从此处进入上文提到,需要从 8111 端口进入才能访问内网,所以我需要对 nginx 添加一些配置:
|
||||
|
||||

|
||||
|
||||
这样,当请求 bitwarden 路径时,将会反向代理到 8111 端口
|
||||
|
||||
当然,通过 nginx 也可以配置子域名来访问,首先将三级域名 bitwarden.yarnom.top 通过 CNAME 指向当前的服务器地址
|
||||
|
||||
然后,nginx 设置一个新的 server ,server_name 就是这个三级域名:
|
||||
|
||||

|
||||
|
||||
|
||||
## 搭建 **[Vaultwarden](https://github.com/dani-garcia/vaultwarden)**
|
||||
|
||||
由于官方的 Bitwarden 服务对于我的小机子来说过于庞大了,所以选择了社区用 Rust 编写 Vaultwarden 项目
|
||||
|
||||
它可以用 docker 搭建,那就是几条命令就能安装完毕:
|
||||
|
||||
```
|
||||
Pull the docker image and mount a volume from the host for persistent storage:
|
||||
|
||||
docker pull vaultwarden/server:latest
|
||||
docker run -d --name vaultwarden -v /vw-data/:/data/ --restart unless-stopped -p 80:80 vaultwarden/server:latest
|
||||
```
|
||||
|
||||
完成!
|
||||
|
||||
|
||||
|
||||
### 搭建 [Memos](https://github.com/usememos/memos)
|
||||
|
||||
docker 搭建
|
||||
|
||||
```
|
||||
docker run -d --name memos -p 5230:5230 -v ~/.memos/:/var/opt/memos ghcr.io/usememos/memos:latest
|
||||
```
|
||||
|
||||
自行修改参数即可。
|
||||
|
||||
完成!
|
||||
17
content/posts/incomprehensible.md
Normal file
17
content/posts/incomprehensible.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
id: f274e117-b322-421d-b13a-5fe9130953fd
|
||||
slug: incomprehensible
|
||||
title: 不知所云
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-07T09:26:53+08:00"
|
||||
created_at: "2024-11-19T17:12:00+08:00"
|
||||
updated_at: "2026-01-07T18:12:15+08:00"
|
||||
---
|
||||
|
||||
不必焦虑于未来,最大的苦痛无非也就是死亡。不必焦虑于现在,最大的灾难无非也就是苟活。开心自由地活下去!
|
||||
21
content/posts/july-and-august.md
Normal file
21
content/posts/july-and-august.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: 2f903051-143f-4501-a562-e66516da76b2
|
||||
slug: july-and-august
|
||||
title: 七月与八月
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-08-08T22:51:43+08:00"
|
||||
created_at: "2023-08-08T22:51:43+08:00"
|
||||
updated_at: "2026-01-07T18:11:43+08:00"
|
||||
---
|
||||
|
||||
七月就这样过去了呢,很久没有写博文了,主要是生活现在千篇一律,没啥想写的内容,总不能写一大堆的流水账不是嘛。
|
||||
|
||||
虽说是千篇一律,但最近也是有好好用功的。最近在捣鼓一个新的项目:用 Rust 语言完成静态博客生成器。我目前使用的 Hexo 就是这样的工具,它将markdown文件转换为html文件,再对 tags 和 categories 等内容进行整理。这是之前未曾接触过的代码经历。
|
||||
|
||||
事实上,一开始为真的无从下手,于是我开始询问 ChatGPT,这真是一个很棒的人工智能,它认真地回答了我很多的问题,给予了我很多实际地代码。我用了一天时间了解了 rust 的语法,囫囵吞枣地学习显然不足以支撑我完成这个项目,但是有了 ChatGPT 的帮助,事情真的变得容易了很多,之后完成了这个项目,我会写一篇更详细的文章来介绍这一过程。
|
||||
21
content/posts/junior-year-second-semester.md
Normal file
21
content/posts/junior-year-second-semester.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: eef1fd63-8d3d-4080-938b-4a15b37f47c8
|
||||
slug: junior-year-second-semester
|
||||
title: 大三下学期
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-02-24T23:25:19+08:00"
|
||||
created_at: "2023-02-24T23:25:19+08:00"
|
||||
updated_at: "2026-01-07T18:11:05+08:00"
|
||||
---
|
||||
|
||||
时间真的过得很快,大一时会在校园迷路的我如今也在面临考研或者直接工作的两个方向,如今的倾向是考研,因为随着 Google 、字节 这些大厂的裁员情况来看,互联网行业并不景气,加之 AI 的高速发展,低技术人员被快速淘汰,可能,提升自己的学历会是一个更好的选择。
|
||||
|
||||
|
||||
|
||||
我的选择是广西大学,这是一个计算机 B- 的学校,综合下来是很适合我的情况的。
|
||||
46
content/posts/linux-process-learning-1.md
Normal file
46
content/posts/linux-process-learning-1.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
id: 0e4f5039-4c89-4465-a8bb-116ac188dd1b
|
||||
slug: linux-process-learning-1
|
||||
title: Linux 进程学习 一
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-10-16T15:54:11+08:00"
|
||||
created_at: "2022-10-16T15:54:11+08:00"
|
||||
updated_at: "2026-01-07T18:10:40+08:00"
|
||||
---
|
||||
|
||||
## 什么是进程?
|
||||
在了解进程的时候,首先得知道什么是程序。
|
||||
操作系统其实学过,程序被执行时,操作系统将可执行文件复制到内存中,这就是程序,而进程则是程序的实例,是系统资源分配的基本单位,它被唯一标识于 PCB 之中,也就是进程控制块。
|
||||
|
||||
## ps 命令 查看进程
|
||||
>Note that ps -aux is distinct from ps aux. The POSIX and UNIX standards require that ps -aux print all processes owned by a user named x, as well as printing all processes that would be selected by the -a option. If the user named x does not exist, this ps may
|
||||
interpret the command as ps aux instead and print a warning. This behavior is intended to aid in transitioning old scripts and habits. It is fragile, subject to change, and thus should not be relied upon.
|
||||
|
||||
>To see every process on the system using BSD syntax:
|
||||
ps ax
|
||||
ps axu
|
||||
|
||||
也就是说,我们可以通过 **ps axu** 来查看系统中的进程
|
||||
|
||||
## top 命令 查看进程
|
||||
> The top program provides a dynamic real-time view of a running system.
|
||||
|
||||
这个命令显示的进程状态是动态更新的。
|
||||
以下是常用命令:
|
||||
- q:退出top命令
|
||||
- <Space>:立即刷新
|
||||
- s:设置刷新时间间隔
|
||||
- t:显示或隐藏进程和CPU状态信息
|
||||
- m:显示或隐藏内存状态信息
|
||||
- P:按%CPU使用率排行
|
||||
- M:按%MEM排行
|
||||
- u:指定显示用户进程
|
||||
- k:kill进程
|
||||
- i:只显示正在运行的进程
|
||||
- h:帮助命令。
|
||||
39
content/posts/linux-process-learning-2.md
Normal file
39
content/posts/linux-process-learning-2.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
id: cf7cfd71-d2ac-4f5a-a175-808ef410a339
|
||||
slug: linux-process-learning-2
|
||||
title: Linux 进程学习 二
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-10-16T16:13:33+08:00"
|
||||
created_at: "2022-10-16T16:13:33+08:00"
|
||||
updated_at: "2026-01-07T18:10:43+08:00"
|
||||
---
|
||||
|
||||
## 创建进程
|
||||
在 Linux 中每个进程都是父进程创建的,Linux 启动时会创建init 进程,这是系统的第一个进程,其 PID 为 1。
|
||||
在 C 语言中,我们可以用fork函数创建新的进程:
|
||||
```c
|
||||
#include <unistd.h>
|
||||
pid_t fork(void);
|
||||
```
|
||||
- If fork() returns a negative value, the creation of a child process was unsuccessful.
|
||||
- fork() returns a zero to the newly created child process.
|
||||
- fork() returns a positive value, the process ID of the child process, to the parent. The returned process ID is of type pid_t defined in sys/types.h. Normally, the process ID is an integer. Moreover, a process can use function getpid() to retrieve the process ID assigned to this process.
|
||||
|
||||
也就是:
|
||||
> On success, the PID of the child process is returned in the
|
||||
parent, and 0 is returned in the child. On failure, -1 is
|
||||
returned in the parent, no child process is created, and errno is
|
||||
set to indicate the error.
|
||||
|
||||
也就是:
|
||||
- 返回负数,创建失败
|
||||
- 返回 0 ,就是子进程,它永远返回 0
|
||||
- 返回大于 0 的整数,这是父进程,它会返回子进程的pid
|
||||
|
||||
## 终止进程
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
id: "107d00b2c9abbfffc0a5f5f407e89834"
|
||||
slug: "my-post"
|
||||
title: "My Post"
|
||||
summary: ""
|
||||
status: "published"
|
||||
tags: ["go", "astro"]
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: "manual"
|
||||
slug_locked: false
|
||||
published_at: "2026-05-28T12:13:37+08:00"
|
||||
created_at: "2026-05-28T12:13:37+08:00"
|
||||
updated_at: "2026-05-28T12:13:37+08:00"
|
||||
---
|
||||
|
||||
Write your post here.
|
||||
25
content/posts/nas-setup-plan.md
Normal file
25
content/posts/nas-setup-plan.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
id: 5c72afc0-ae9a-4e79-9add-c94556dbbc32
|
||||
slug: nas-setup-plan
|
||||
title: NAS 组建计划
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-01T04:14:16+08:00"
|
||||
created_at: "2024-06-07T05:22:57+08:00"
|
||||
updated_at: "2026-01-07T18:12:04+08:00"
|
||||
---
|
||||
|
||||
近些日子看了很多有关机械硬盘的视频,遂萌生了组一台NAS的想法。
|
||||
|
||||
目前我其实是有类NAS的备份系统的,使用的是一台OrangePi 1G RAM ,老实说这个派内存买小了,而且它似乎没有硬件解码,导致我用它解析视频的时候有些吃力,所以在考虑要不要换回x86的cpu 重新组一台 NAS,大机箱还能放很多的机械硬盘。
|
||||
|
||||
Orange Pi 配合 Cloudflare 和 frp 进行内网穿透,虽然速度慢了一些,但是确实也不是不能用,配合 Syncthing 这种去中心化的同步工具使用下来也是挺好的,所以我再想什么呢,是新配一个 itx 大小的电脑,还是说重新考虑一个内存大一些的 ARM Pi。
|
||||
|
||||
这段时间将两种方案都写出来好好考虑考虑。
|
||||
|
||||
(未完)
|
||||
17
content/posts/neovim-tutor-documentation.md
Normal file
17
content/posts/neovim-tutor-documentation.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
id: c335d5a6-4a96-489b-a642-21798693db6c
|
||||
slug: neovim-tutor-documentation
|
||||
title: 看完了 NeoVim 的 Tutor 文档
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-05-20T20:37:16+08:00"
|
||||
created_at: "2023-05-20T20:37:16+08:00"
|
||||
updated_at: "2026-01-07T18:11:31+08:00"
|
||||
---
|
||||
|
||||
今天闲得没事干,于是想起了可以看看 NeoVim 提供的入门文档,是的,它是全英文的,但是里面的单词还是很简单的,颇为流畅地通读了一遍。之前只是知道一些零零散散的命令,如今感觉好多了,又学习到了很多新的知识。
|
||||
41
content/posts/nfs-mount-connection-timed-out.md
Normal file
41
content/posts/nfs-mount-connection-timed-out.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
id: 53dd96ff-65e1-4ee9-8cb4-c8130e2eb0d5
|
||||
slug: nfs-mount-connection-timed-out
|
||||
title: '[NFS] mount: Connection timed out'
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-04-06T20:09:29+08:00"
|
||||
created_at: "2023-04-06T20:09:29+08:00"
|
||||
updated_at: "2025-12-31T23:58:09+08:00"
|
||||
---
|
||||
|
||||
## 问题描述
|
||||
Linux 服务端和 Arm 开发板客户端进行 NFS 服务的连接。
|
||||
|
||||
Linux 和 Arm 开发板之间能ping通,并且处于同一网端,且掩码、网口相同,但是在执行下面的程序时发生超时错误。
|
||||
|
||||
在服务端的nfs配置完全正确的情况下,Arm开发板还是无法正确连接nfs服务器。
|
||||
|
||||
```bash
|
||||
[root@FORLINX6410]# mount -t nfs -o nolock,hard 192.168.1.5:/home/yarnom/nfs /mnt
|
||||
mount: mounting 192.168.1.5:/home/yarnom/nfs on /mnt failed: Connection timed out
|
||||
|
||||
```
|
||||
|
||||
## 解决
|
||||
这个问题困扰了我两天终于在这个[帖子](https://stackoverflow.com/questions/45938202/mount-nfs-connection-timed-out-on-ubuntu-14-04-1-lts)里找到了解决方案。
|
||||
> Mount the NFS filesystem using the TCP protocol instead of the default UDP protocol. Many NFS servers only support UDP.
|
||||
|
||||
这是在NFSv3中添加了对TCP协议的支持:
|
||||
|
||||
总之,我尝试了下面的命令,使用了tcp协议:
|
||||
```bash
|
||||
$ mount -t nfs -o nolock,proto=tcp,port=2049 192.168.1.5:/home/yarnom/nfs /mnt
|
||||
```
|
||||
|
||||
这个协议最终使我正确连接上了nfs服务器。
|
||||
21
content/posts/open-up.md
Normal file
21
content/posts/open-up.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: da6031ed-349a-46ae-be0a-9fa8ff5600af
|
||||
slug: open-up
|
||||
title: 放开
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-12-08T22:05:53+08:00"
|
||||
created_at: "2022-12-08T22:05:53+08:00"
|
||||
updated_at: "2026-01-07T18:10:54+08:00"
|
||||
---
|
||||
|
||||
今天全国范围内都不同程度地放开疫情管控,疫苗接种率和药品的准备不知道是否充足,白纸革命是成功的,但接下来注定会被污名化,这是必然的。
|
||||
|
||||
在这三年中,我看到了中央与地方的割裂,虽然说公有制使得中国政府能够调用大量的资源,但是私有制和利欲薰心的人始终存在,权利最终被滥用,或许这其实就是真实的中国。我看到了一个新的事物不断走向灭亡的过程。
|
||||
|
||||
但是这是我出生的地方,我爱这片土地,我爱我的家人朋友,每个国家都有着这样或那样的矛盾,我们的国家也不例外,它终究不是儿时课本所描述的那个完美的国家、政府,他们或许已经是最适合的选择了。
|
||||
19
content/posts/pain.md
Normal file
19
content/posts/pain.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: 501c6842-c113-48f4-8dc3-d53655918c38
|
||||
slug: pain
|
||||
title: 痛苦
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-07T09:26:42+08:00"
|
||||
created_at: "2025-01-16T11:13:00+08:00"
|
||||
updated_at: "2026-01-07T09:26:42+08:00"
|
||||
---
|
||||
|
||||
事情一直没有结果,而努力被认定为理所当然,一旦犯下错误便被严加训责,脑子昏昏沉沉的,讨厌这个社会,讨厌身边的人,看得到头的生命,我明白自己是普通人,但是但是,我真的走不下去了,苟活着,三餐维持得了自己的生命。朝生暮死,何异于虫蝼,无非是稍久几年罢了……怎么会这样啊
|
||||
|
||||
劳苦作兮终徒然,哀吾身兮悲且贱,信念几将殆。
|
||||
24
content/posts/pink-flowers.md
Normal file
24
content/posts/pink-flowers.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
id: 595e5f42-9e96-4f71-87b4-8aee200e86be
|
||||
slug: pink-flowers
|
||||
title: 粉粉的花
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-14T18:04:52+08:00"
|
||||
created_at: "2025-07-03T02:37:00+08:00"
|
||||
updated_at: "2026-01-14T18:04:52+08:00"
|
||||
---
|
||||
|
||||
最近入手了一枚老胶片微距镜头——尼康 55mm F2.8。原本打算用来翻拍胶片,但翻拍流程需要的支架和工具还没凑齐,所以暂时先拿它拍些花草。
|
||||
|
||||
这张照片是我目前觉得最好的一张:色彩和景深都很理想。微距镜头能贴近被摄物“怼拍”,画面质感很好。不过,这类全手动镜头在对焦时确实麻烦。拍摄时在相机的小屏幕上看着似乎没问题,但等到回到家在电脑上放大细看,却常常一眼就发现对焦没准。 
|
||||
|
||||
我的机身是松下 G97,它的绿色表现一向讨喜,不愧于被大家称作“松下绿”。而老胶片镜头自带一种油润柔和的质感,两者叠加在一起,让绿色既不显得过度饱和,又能保持层次与通透。画面中那种微微克制的绿,既真实又舒适,肉眼看上去格外耐看。
|
||||
|
||||
或许这也是老镜头和数码机身的奇妙互补:一个提供了温润的调性,一个保留了细节的锐利。拍出来的照片,既有现代的清晰,又带点复古的韵味。  比较可惜的是,这一张其实对焦失败了,这次焦点却落在了后面的叶子上。 
|
||||

|
||||
36
content/posts/react-frontend-load-optimization.md
Normal file
36
content/posts/react-frontend-load-optimization.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
id: b93ebdad-9537-4e8b-a8c0-c2ba08f34307
|
||||
slug: react-frontend-load-optimization
|
||||
title: React 前端加载优化
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-10-11T18:13:47+08:00"
|
||||
created_at: "2022-10-11T18:13:47+08:00"
|
||||
updated_at: "2026-01-07T18:10:36+08:00"
|
||||
---
|
||||
|
||||
## 主要问题
|
||||
这次的 December 项目采用的是前后端分离的技术,交互使用的是 Axios ,但目前遇到的问题是,首次加载网站的时间过于久了,根据 report 的分析,我认为可能存在的问题是 react-router-dom 一次性加载了所有的组件,导致了加载变慢。
|
||||
|
||||
所以,主要的解决策略集中在对 router 的懒加载上,让它分批次地加载组件。
|
||||
|
||||
当然我还怀疑 React-Mui 组件库也拖慢了网站的加载,毕竟它是有些大的,但是应该还好,毕竟加载时是分开导入的,先解决 Router 的问题吧
|
||||
|
||||
## 解决方案
|
||||
导入 lazy,Suspense 包
|
||||
```js
|
||||
import { lazy,Suspense } from 'react';
|
||||
```
|
||||
lazy 用于懒加载 , Suspense 用于加载 loading
|
||||
```js
|
||||
const MyComponent = lazy(() => import('./MyComponent'))
|
||||
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
```
|
||||
24
content/posts/reinstall-archlinux.md
Normal file
24
content/posts/reinstall-archlinux.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
id: b4261147-24b4-400a-8762-e60044ae6d1b
|
||||
slug: reinstall-archlinux
|
||||
title: 重新安装 Archlinux
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-05-03T11:22:11+08:00"
|
||||
created_at: "2023-05-03T11:22:11+08:00"
|
||||
updated_at: "2026-01-07T18:11:25+08:00"
|
||||
---
|
||||
|
||||
## 前言
|
||||
最近系统频繁死机,通过初步判断,应该是图形界面的卡死,但不合理的是 —— 我无法通过魔法键退出到tty界面……
|
||||
|
||||
我尝试了复现,但这似乎是没有规律的,我其实开始怀疑是我的SSD的问题了,但转头用了两天DWM,期间没有出现卡死现象,那问题很大可能就是图形界面卡死,Gnome和我目前的系统内核不匹配?
|
||||
|
||||
总之,我决定对系统做一次全面的重置。
|
||||
|
||||
这里放一个 Chrome 插件: [Ghelper](/download/Ghelper-v2.8.2.crx)
|
||||
24
content/posts/return-to-heart.md
Normal file
24
content/posts/return-to-heart.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
id: 472d4243-c01b-465b-99d0-44fc1ad90455
|
||||
slug: return-to-heart
|
||||
title: 归于心
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2024-06-05T13:54:55+08:00"
|
||||
created_at: "2024-06-05T13:54:55+08:00"
|
||||
updated_at: "2026-01-07T18:12:01+08:00"
|
||||
---
|
||||
|
||||
今天是6月7日,果然还是得工作,不工作闲下来就乱想东西
|
||||
|
||||
---
|
||||
抑郁的情绪最近确实一直困扰着我,现实对我的压迫越来越大,我真的好害怕,恐惧且畏缩贯穿了我这几年的每一个决定,即使是那些看着很勇敢的行为,深究下来不过也就是为了逃避。我真的像舟之行于大海,摇摇荡荡也找不到可以依靠的地方,原来可以避风的港口催促着我离开,我害怕大雨,我感觉我一碰到这些就会被刮得零落。
|
||||
|
||||
原本不是这样的,至少在暴风雨来得没有那么频繁的以前,我有听说过“锚”,这是船不再前进时为了不被风浪推走而固定时所用到的,我没有勇气也没有能力再向前,后退又是不被允许,所以只能依靠着锚固定着,不让这些浪催促着移动位置,然而问题就是出现在这些锚上面,我开始恐惧现实,锚开始不起作用了,我能清晰地感受到那种抑郁的情绪,就像心里始终有一股失落的云笼罩着,莫名地害怕,莫名地心怯。具体体现在现实中无论是好的还是坏的事情,我都不想去接触,不想接触人,不想接触事。我很明白这样的情绪是不对的,但是不这样,我的心就好慌……
|
||||
|
||||
寓形宇内复几时,曷不委心任去留,我真的无法再劝慰自己努力地面对现实。
|
||||
246
content/posts/rtcm-1005-parsing.md
Normal file
246
content/posts/rtcm-1005-parsing.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
---
|
||||
id: 3bbc87e8-55cd-4e53-8589-3fc76f6d746e
|
||||
slug: rtcm-1005-parsing
|
||||
title: RTCM 1005 解析
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-07T09:27:22+08:00"
|
||||
created_at: "2024-07-19T13:02:26+08:00"
|
||||
updated_at: "2026-01-07T09:27:22+08:00"
|
||||
---
|
||||
|
||||
### 前言
|
||||
|
||||
RTCM 是一种普遍采用的数据传输格式,它是由国际海运事业无线电技术委员会提出的,用于制定在差分全球导航定位系统和实时动态操作时使用的标准。
|
||||
|
||||
下面是技术规范文档:
|
||||
|
||||
- [RTCM3.3 c10403.3_all.PDF](https://rtcm.myshopify.com/products/rtcm-10403-3-differential-gnss-global-navigation-satellite-systems-services-version-3-amendment-2-may-20-2021)
|
||||
- [北云科技技术文档](http://www.bynav.cn/media/upload/cms_15/AN018_RTCM3.2%E6%A0%BC%E5%BC%8F%E8%AF%B4%E6%98%8E_%E5%8C%97%E4%BA%91%E7%A7%91%E6%8A%80.pdf)
|
||||
|
||||
第一个文档是官方的,包含了很多的细节,但是需要付费,虽说有其他方式获得,但版权原因还是不在此贴出;
|
||||
|
||||
第二个文档是北云科技的,中文文档虽说少了一些细节,但终究方便阅读,虽然本文不会参考它 :)
|
||||
|
||||
|
||||
|
||||
### 文档解读
|
||||
|
||||
RTK的消息类型有很多,初看会很懵,但在只要找到自己想要了解的消息类型即可,在文档的 **3.2 节 Message Type Summary** 中有详细的介绍,本文主要关注 1005 这个消息类型,它由 19 个字节也就是 152 个比特构成,它主要包含基准站位置数据信息,也就是基站的 ECEF 的坐标。
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
在正式阅读之前,还需要了解一下数据结构和类型,在文档的 **3.3节 Data Types** 有详细的介绍:
|
||||
|
||||
数据类型很好理解,它规定了数据的范围和构成,后面写程序做转换的时候要严格按照本节的数据结构来实现。
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
下面是这个协议具体的内容, 后面会详细分析:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
### 解算
|
||||
|
||||
下面给出一段 RTCM 1005 的原始码流,它是十六进制的 ASCII :
|
||||
|
||||
````
|
||||
D3 00 13 3E D7 D3 02 02 98 0E DE EF 34 B4 BD 62 AC 09 41 98 6F 33 36 0B 98
|
||||
````
|
||||
|
||||
在手动计算之前,还需要了解一下它在传输时候的格式,RTCM 数据以帧的形式的传输
|
||||
|
||||

|
||||
|
||||
- Preamble : 同步码(8 bits) , 11010011 ,其十六进制为 'D3'
|
||||
- Reserved : 保留(6 bits),000000,十六进制为 00
|
||||
- Message Length: 长度(10 bits),数据信息的长度,以字节数表示
|
||||
- Variable Length Data Message(0-1023 bytes): 可变长数据消息,长度不定,最大 1023 bytes
|
||||
- CRC:校验(24 bits)
|
||||
|
||||
所以 RCTM 1005 每帧大致长这样: **1101 0011 0000 00 + 长度 + 数据 + 校验**
|
||||
|
||||
开头固定为 : **1101 0011 0000 00** ,十六进制就是 **D30** ,我们再回头看一下原始码流:
|
||||
|
||||
````
|
||||
D3 00 13 3E D7 D3 02 02 98 0E DE EF 34 B4 BD 62 AC 09 41 98 6F 33 36 0B 98
|
||||
````
|
||||
|
||||
前三个十六进制 ASCII 码 我们就能解析出来了,后面紧跟着的是消息长度,它使用 10 个 bit 存储
|
||||
|
||||
````D3 00 13
|
||||
Hex: D3 00 13
|
||||
Binary: 1101 0011 0000 0000 0001 0011
|
||||
````
|
||||
|
||||
所以,同步码 + 保留 + 长度 总共就用了 24 个 bit ,也就是 3 个字节
|
||||
|
||||
接下来就是可变长度的数据消息了,下面的表格就是 1005 类型的数据内容:
|
||||
|
||||
````
|
||||
DATA FIELD DF NUMBER DATA TYPE NO. OF BITS
|
||||
-------------------------------------------------------------------------------
|
||||
Message Number ("1005"=0011 1110 1101) DF002 uint12 12
|
||||
Reference Station ID DF003 uint12 12
|
||||
Reserved for ITRF Realization Year DF021 uint6 6
|
||||
GPS Indicator DF022 bit(1) 1
|
||||
GLONASS Indicator DF023 bit(1) 1
|
||||
Reserved for Galileo Indicator DF024 bit(1) 1
|
||||
Reference-Station Indicator DF141 bit(1) 1
|
||||
Antenna Reference Point ECEF-X DF025 int38 38
|
||||
Single Receiver Oscillator Indicator DF142 bit(1) 1
|
||||
Reserved DF001 bit(1) 1
|
||||
Antenna Reference Point ECEF-Y DF026 int38 38
|
||||
Quarter Cycle Indicator DF364 bit(2) 2
|
||||
Antenna Reference Point ECEF-Z DF027 int38 38
|
||||
-------------------------------------------------------------------------------
|
||||
TOTAL 152
|
||||
````
|
||||
|
||||
|
||||
|
||||
#### Message Number ("1005"=0011 1110 1101)
|
||||
|
||||
它的长度 12 个比特,也就是 3 个 Hex Ascii 码:
|
||||
|
||||
```
|
||||
Hex: 3E D
|
||||
Bin: 0011 1110 1101
|
||||
Dec: 1005
|
||||
```
|
||||
|
||||
将十六进制的 3ED Ascii 码转换为 十进制的数字,我们可以看到它正好是 **1005**
|
||||
|
||||
#### Reference Station ID
|
||||
|
||||
```
|
||||
Hex: 7 D3
|
||||
Bin: 0111 1101 0011
|
||||
Dec: 2003
|
||||
```
|
||||
|
||||
#### Reserved for ITRF Realization Year
|
||||
|
||||
Hex:02 02
|
||||
Bin:**0000 00**1 00000 0010(取前 6 位 ,也就是 0000 00 = 0)
|
||||
|
||||
#### GPS Indicator
|
||||
|
||||
Hex: 02 02
|
||||
|
||||
Bin:0000 00**1 **00000 0010 (取第 7 位 ,也就是 1)
|
||||
|
||||
#### GLONASS Indicator
|
||||
|
||||
Hex: 02 02
|
||||
|
||||
Bin:0000 001**0** 0000 0010 (取第 8 位 ,也就是 0)
|
||||
|
||||
#### Reserved for Galileo Indicator
|
||||
|
||||
Hex: 02 02
|
||||
|
||||
Bin:0000 0010 **0**000 0010 (取第 9 位 ,也就是 0)
|
||||
|
||||
#### Reference-Station Indicator
|
||||
|
||||
Hex: 02 02
|
||||
|
||||
Bin:0000 0010 0**0**00 0010 (取第 10 位 ,也就是 0)
|
||||
|
||||
#### Antenna Reference Point ECEF-X
|
||||
|
||||
它的长度是 38 bits
|
||||
|
||||
Hex: 02 98 0E DE EF
|
||||
|
||||
Bin: 00**00 0010 1001 1000 0000 1110 1101 1110 1110 1111**
|
||||
|
||||
Dec: 11141045999
|
||||
|
||||
#### Single Receiver Oscillator Indicator
|
||||
|
||||
Hex: 34
|
||||
|
||||
Bin: **0**011 0100 (取第 1 位, 也就是0)
|
||||
|
||||
#### Reserved
|
||||
|
||||
Hex: 34
|
||||
|
||||
Bin: 0**0**11 0100 (取第 2 位, 也就是0)
|
||||
|
||||
#### Antenna Reference Point ECEF-Y
|
||||
|
||||
它的长度是 38 bits
|
||||
|
||||
Hex: 34 B4 BD 62 AC
|
||||
|
||||
Bin: 00**11 0100 1011 0100 1011 1101 0110 0010 1010 1100**
|
||||
|
||||
二进制最高位是 1 ,为负数,所以要取反再加1
|
||||
|
||||
补码: 1**0 1011 0100 1011 0100 0010 1001 1101 0101 0100**
|
||||
|
||||
除去第一位的符号位,后面加粗的就是实际的数值
|
||||
|
||||
Bin: 0 1011 0100 1011 0100 0010 1001 1101 0101 0100
|
||||
|
||||
Hex: 4B 4B 42 9D 54
|
||||
|
||||
Dec: 48507297108
|
||||
|
||||
加上符号位就是 -48507297108
|
||||
|
||||
#### Quarter Cycle Indicator
|
||||
|
||||
Hex: 09
|
||||
|
||||
Bin: **00** 00 1001(取前两位, 也就是 00)
|
||||
|
||||
#### Antenna Reference Point ECEF-Z
|
||||
|
||||
Hex : 09 41 98 6F 33
|
||||
|
||||
Bin: 00**00 1001 0100 0001 1001 1000 0110 1111 0011 0011**
|
||||
|
||||
Dec: 39755214643
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
到此为止,手动结算就已完成,若是关注 ECEF XYZ 的取值,则需要回看数据类型,它们的数据类型是 DF025、DF026、DF027
|
||||
|
||||

|
||||
|
||||
这张图详细地说明了 ECEF XYZ 的数据类型,数据范围,数据处理
|
||||
|
||||
其中,它是 int38 的有符号38位整数,数据是 0.0001 m
|
||||
|
||||
上面我们算得的数,都应做一个处理:
|
||||
|
||||
ECEF-X : 11141045999 * 0.0001 = 1114104.5999m
|
||||
|
||||
ECEF-Y: 48507297108*0.0001= -4850729.7108m
|
||||
|
||||
ECEF-Z:39755214643*0.0001 = 3975521.4643m
|
||||
|
||||
|
||||
|
||||
(完)
|
||||
19
content/posts/rust-linux-kernel-second-language.md
Normal file
19
content/posts/rust-linux-kernel-second-language.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: 6f7969b1-2ad3-4350-9ac4-fb829c0c86d8
|
||||
slug: rust-linux-kernel-second-language
|
||||
title: Rust 即将成为 linux 内核的第二语言
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-10-11T13:04:48+08:00"
|
||||
created_at: "2022-10-11T13:04:48+08:00"
|
||||
updated_at: "2026-01-07T18:10:34+08:00"
|
||||
---
|
||||
|
||||
今天又看到了一则消息,2022年 Rust 将成为 Linux 内核第二官方语言。
|
||||
|
||||
我认为这绝对是一个好消息。
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
---
|
||||
id: ca4bf458-6874-401f-a49f-71eda9ec3e17
|
||||
slug: rustic-pages-requirements-development-documentation
|
||||
title: Rustic-pages 需求与开发文档
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-08-31T12:36:47+08:00"
|
||||
created_at: "2023-08-31T12:36:47+08:00"
|
||||
updated_at: "2026-01-07T18:11:49+08:00"
|
||||
---
|
||||
|
||||
## 1. 引言
|
||||
|
||||
要求使用 Rust 编写一个高效且易于使用的静态博客生成器,它将能正确处理 Markdown 和 SCSS 文件的文件。
|
||||
|
||||
它将支持以下命令行指令:
|
||||
|
||||
- `init`: 初始化程序,创建相应文件夹及内容
|
||||
|
||||
- `generate` : 渲染和生成所有的静态网站内容
|
||||
- `clean` : 清除缓存
|
||||
- `server` : 本地部署服务,该指令应该默认调用 `clean` 和 `generate`
|
||||
- `deploy` : 自动部署静态文件到指定仓库
|
||||
|
||||
|
||||
|
||||
所有的内容应该均以变量的形式预先准备,然后再根据规则渲染文件。
|
||||
|
||||
## 2. 功能需求
|
||||
|
||||
### 2.1 博文编辑
|
||||
|
||||
- 用户可以创建、编辑和删除博文。
|
||||
- 博文内容支持 Markdown 格式,允许用户使用 Markdown 语法编写博文。
|
||||
- 用户可以设置博文的标题、发布日期、标签和归档。
|
||||
- 用户可以上传图片和附件,并在博文中引用它们。
|
||||
- 用户可以保存博文为草稿,随时继续编辑。
|
||||
|
||||
### 2.2 博文生成
|
||||
|
||||
- 用户可以使用命令行工具生成静态网站。
|
||||
- 博客生成器将从用户指定的目录中读取博文和资源文件,并生成静态 HTML 文件。
|
||||
- 用户可以指定生成的网站目标路径。
|
||||
- 生成的网站应包括主页、博文详细页、归档页、分类页面和标签页面。
|
||||
|
||||
### 2.3 主题和样式
|
||||
|
||||
- 用户可以选择不同的博客主题和样式。
|
||||
- 博客生成器应支持多个默认主题,用户也可以自定义主题。
|
||||
- 用户可以自定义网站的样式表(CSS)。
|
||||
|
||||
|
||||
|
||||
## 3. 功能模块
|
||||
|
||||
### 3.1 数据结构
|
||||
|
||||
#### 3.1.1 Post
|
||||
|
||||
- title: String
|
||||
|
||||
- date: String
|
||||
|
||||
- date_simple : String
|
||||
|
||||
- published: bool
|
||||
|
||||
- tags: Vec<String>
|
||||
|
||||
- categories: Vec<String>
|
||||
|
||||
- raw_content: String
|
||||
|
||||
- content: String
|
||||
|
||||
- slug: String
|
||||
|
||||
- link: String
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#### 3.1.2 Page
|
||||
|
||||
- title: String,
|
||||
|
||||
- date: String,
|
||||
|
||||
- template: String,
|
||||
|
||||
- raw_content: String,
|
||||
|
||||
- content: String,
|
||||
|
||||
- limited_cows:usize,
|
||||
|
||||
- total: usize,
|
||||
|
||||
- current: usize,
|
||||
|
||||
- prev:usize,
|
||||
|
||||
- prev_link: String,
|
||||
|
||||
- next:usize,
|
||||
|
||||
- next_link: String
|
||||
|
||||
|
||||
|
||||
#### 3.1.3 Archive
|
||||
|
||||
- posts - Vec<Post>
|
||||
- pages: Vec<Page>
|
||||
- tags: HashMap<String,Vec<& Post>>
|
||||
- categories: HashMap<String,Vec<& Post>>
|
||||
|
||||
|
||||
|
||||
#### 3.1.4 Config
|
||||
|
||||
pass
|
||||
|
||||
### 3.2 构建逻辑(Generate)
|
||||
|
||||
1. 程序首先构造一个 Archive 实例,用于保存接下来所有的变量数据。
|
||||
|
||||
2. Post 处理过程
|
||||
|
||||
1. 从 /source/content/posts 中获取所有的 markdown 文件,并进行解析,封装为一个可操作的 Post 数据实例。
|
||||
2. 所有的Post 插入到 Archive.posts
|
||||
|
||||
3. Tag 处理过程
|
||||
|
||||
1. 遍历 Archive.posts 将每个Post 内的 Tag 解析
|
||||
2. 所有的 Tag 插入到 Archive.tags
|
||||
|
||||
4. Category 处理过程
|
||||
|
||||
1. 遍历 Archive.posts 将每个Post 内的 Category 解析
|
||||
2. 所有的 Category 插入到 Archive.categories
|
||||
|
||||
5. 页面构造逻辑
|
||||
|
||||
1. 从 /source/content/pages 中获取所有的 markdown 文件,进行解析,封装为一个可操作的 Page 数据实例
|
||||
2. 所有的 Page 插入到 Archive.pages
|
||||
3. 若是front-matter 带有 pagination: tags,则对 Archive.tags 触发分页
|
||||
4. 若是front-matter 带有 template: xxx ,则渲染模板,否则默认输出 content
|
||||
|
||||
|
||||
|
||||
### 3.3 路径渲染逻辑
|
||||
|
||||
- Index - Page
|
||||
- /public/index.html
|
||||
- /public/page/N/index.html
|
||||
- Archive
|
||||
- /public/archive/posts/2023/12/10/hello/index.html
|
||||
- /public/archive/tags/技术/index.html
|
||||
- /public/archive/categories/算法/index.html
|
||||
- Tags - Page
|
||||
- /public/tags/index.html
|
||||
- /public/tags/page/N/index.html
|
||||
- Categories - Page
|
||||
- /public/categories/index.html
|
||||
- /public/categories/page/N/index.html
|
||||
- Custom Page
|
||||
- /public/custom/index.html
|
||||
- /public/custom/page/N/index.html
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## README 文档
|
||||
|
||||
A lightweight static blog generator developed using Rust
|
||||
|
||||
## Usage
|
||||
You can compile the source code of this project using the cargo build command.
|
||||
```shell
|
||||
cargo build
|
||||
```
|
||||
When you run cargo build, it searches for the Cargo.toml file in the current directory and builds the project based on the configuration information specified in that file.
|
||||
`Cargo.toml` is the configuration file for Rust projects and contains the project's dependencies and other build configurations.
|
||||
46
content/posts/september-october.md
Normal file
46
content/posts/september-october.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
id: 6ed69ec2-56f1-49f9-8e92-908cc0f5d7c3
|
||||
slug: september-october
|
||||
title: 九月与十月
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-10-12T06:33:02+08:00"
|
||||
created_at: "2023-10-12T06:33:02+08:00"
|
||||
updated_at: "2026-01-07T18:11:54+08:00"
|
||||
---
|
||||
|
||||
这个月继续完善 rustic-pages ,这个项目是八月的时候发起的,十月我对它进行了一次重构,目前已经基本完成,代码并没有进行什么模块的拆分,因为我还没完全吃透 rust ,随意套用一些模式会出现意想不到的问题。
|
||||
|
||||
因为 rust 的特性,引用借用这些很严格,所以在 0.1.4 以前的版本中,我并没有使用生命周期,所以这不可避免得导致了解析和生成的缓慢,但这仍然是可以接受的。在重构之前,我对 rust 的生命周期理解得还不充分,在拆分模块之后,代码几乎无法正确运行,同时依赖的包也无法自动解析这类的引用类型,我想我在学习 rust 的时候必然是缺失了一些知识,我对数据类型的了解还不够充分。
|
||||
|
||||
但是这是一个长期的项目,文档编写的工作也十分繁琐,前期的需求文档已经不足以支持我继续开发了,很多新的特性也在我脑子里蹦出来,我需要更多的时间去完成这些工作。但是目前的我最缺少的就是时间,我仍在准备研究生考试,加之我最近打算弄一个软件著作,时间上总是有些不够呢。
|
||||
|
||||
---
|
||||
|
||||
不同于之前的观念,维护一个项目是有趣的,将脑海里的新特性实现于代码之中,这确实很有成就感。
|
||||
|
||||
当然了,其实我应该多加入一些方便用户使用的脚本或者功能,事实上,目前的版本只能是将 sources 里的 markdown 源文件渲染到 public 里,但是没有实现类似与 Hexo 中的 deploy 指令,这涉及到一些 git 操作,我目前还是不太了解,虽然我在我自己博客中实现了这一脚本,但是我不确定如果这个脚本在其他电脑中会出现些什么。
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
current_datetime=$(date +"%Y-%m-%d %H:%M:%S")
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
cp -r public/* "$TEMP_DIR"
|
||||
cd "$TEMP_DIR"
|
||||
git init
|
||||
|
||||
git add .
|
||||
git commit -m "Update: $current_datetime"
|
||||
git remote add origin "git@github.com:fengyarnom/fengyarnom.github.io.git"
|
||||
git push -f origin "master:gh-pages"
|
||||
# 清理临时目录
|
||||
cd ..
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
```
|
||||
45
content/posts/smoking.md
Normal file
45
content/posts/smoking.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
id: f91ef99b-f7d4-4361-acb2-4217fec19ed7
|
||||
slug: smoking
|
||||
title: 抽烟
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-13T09:25:27+08:00"
|
||||
created_at: "2024-11-15T18:23:00+08:00"
|
||||
updated_at: "2026-01-13T09:25:27+08:00"
|
||||
---
|
||||
|
||||
前段时间我开始抽烟了——仔细想想回溯到上一次抽 “烟” 的时间,那还是小学的时候。
|
||||
|
||||
那时候调皮呀,在家里就将各种纸卷成香烟的长条形状,火机点燃后就叼着这根 “香烟” 尾部吸将起来。
|
||||
|
||||
说起小学,那时候的我不知为何,知识总是不如脑子,自己也笨笨的,每天写不出来作业被老师训斥在门口外面罚站……我妈妈也认为我读完初中可能就要出去打工了,因为我看起来根本不是读书的料。
|
||||
|
||||
人生的转机发生在六年级的时候,我开始接触到一些黑客的电影,进而开始接触到编程,那时候只是觉得很酷罢了,但是学着学着就歪到了编程去了,从 windows 的 bat 脚本到前端的 html css ,我居然真的可以创造出一些东西来,上了初中后更是一发不可收拾,前端后端都开始接触,老实说现在回看以前的代码,那必然是幼稚和简单的,但是这种简单代码的实现确实从而改变了我后面的人生,我开始越学越多,越写越喜欢……学业上也开始好转,我学初中的东西学得很快,成绩渐渐好转,后面也考上了大学,即使是没那么好的大学,但比起辍学打工倒是好上不少。
|
||||
|
||||
说回香烟,因为大部分空闲时间都在编程上,所以香烟这种东西对我来说没有什么机会接触到,年龄和性格的转变让我也不再对其感兴趣,后面更是由于香烟对人体的危害很大的原因,对香烟更是避之不及,所以我的前二十年是抽烟的经历仅仅只是小学时的玩火闹剧罢了。
|
||||
|
||||
真正意义上的接触香烟,是我大学毕业后参加工作的那几个月。在公司写的代码与闲暇时不同,它充斥着赶工、压力、混乱和不完美。
|
||||
|
||||
那段时间的开发压力也极大,我的生命与生活开始不再属于自己,晚上回家睡觉,第二天重复前一天的工作,苦闷无法言说……
|
||||
|
||||
记得开始吸烟的那天晚上加班回家,乏累地躺下打开抖音,一天的自由时间无非也就这几个小时罢了,那天也有些神经质了,很多视频也感觉比平时更有趣,更戳笑点,刚开始上浅浅发生一些笑声,后面则是发声大笑,然而真是喜极而泣,也真是悲从心来,活了二十多年啊,第一次感受到这种在大笑间内心涌出的莫名空洞无助……
|
||||
|
||||
那种无助的情感弥漫到全身,但是我做不了什么,抱着被子和枕头鼻涕眼泪擤了又来,擦了又掉。
|
||||
|
||||
我当时很清楚知道自己的精神出现了问题,但只能任由自己边哭边笑,到了后面则是痛哭,放声地痛哭,好多年没哭过,也好多年没出现这么委屈的情绪了。
|
||||
|
||||
哭过之后,我想到了香烟,这是除开药物外可能对我有帮助的东西了,我不清楚我自己怎么了,但应该是太累了,我的情绪崩溃了。我不在乎它是否对我有害,是否会让我上瘾,我太累太痛苦了,我人生的前二十年,我极力控制自己不要与它产生接触,但是当压力真的降临到自己身上时,当看到自己在出租屋里精神错乱般的大笑大哭时,理智已经不存在了,痛苦混乱占据了大脑,香烟便是廉价的麻醉剂。
|
||||
|
||||
我下来去买了一包上大学时听室友说起过的香烟牌子,回到楼顶天台不熟练地点燃一根,当夜间的风将吐出的烟雾带走,我的情绪也才真正的平复下来。
|
||||
|
||||
我想我应该更爱自己才是,我的生活也不应该如此束缚。
|
||||
|
||||
---
|
||||
|
||||
再之后,项目与困难一起结束,我便没有再吸香烟了。
|
||||
22
content/posts/song-recording.md
Normal file
22
content/posts/song-recording.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
id: f0b41661-11f5-4126-86dc-a8070d3b9d11
|
||||
slug: song-recording
|
||||
title: 记录一首歌
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-11-21T19:11:34+08:00"
|
||||
created_at: "2022-11-21T19:11:34+08:00"
|
||||
updated_at: "2026-01-07T18:10:49+08:00"
|
||||
---
|
||||
|
||||
原唱是:B站UP主 糖醋蛋黄
|
||||
|
||||
月昢昢兮 尘莫莫 几时归矩州啊
|
||||
风萧萧兮 寒漠漠 远隔离思多啊
|
||||
夜行千里做远客 半生半将魂魄赊
|
||||
山离离 水漓漓 荒唐事历历
|
||||
27
content/posts/static-blog-generator.md
Normal file
27
content/posts/static-blog-generator.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
id: e476e5f7-887a-4d66-a054-d8a8e80c51bb
|
||||
slug: static-blog-generator
|
||||
title: 我的静态博客生成器
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-08-27T09:22:56+08:00"
|
||||
created_at: "2023-08-27T09:22:56+08:00"
|
||||
updated_at: "2026-01-07T18:11:47+08:00"
|
||||
---
|
||||
|
||||
近些日子无心向学,于是又手痒折腾起了代码。
|
||||
|
||||
起因是接触到了 Rust 这门语言,并了解到了它的很多特性。我认为这真的是一个很棒的语言并且惊叹于它的性能。正因如此,我决定以此为契机,开始了一个新的项目,一个使用 Rust 编写的静态博客生成器。
|
||||
|
||||
## 项目情况
|
||||
|
||||
目前它只是一个堪堪能用的状态,我甚至还没想好它的名字,现在只是使用 `static-blog-rust-make` 作为项目名字,我并没有想出一个正式的名字。我目前开源在[Github](https://github.com/fengyarnom/static-blog-rust-make) 上,由于时间实在是不够用,我在有限的时间内已经做到了极限,优化代码和新功能的添加只能在日后完成了,但是,我仍然十分满意于这个项目!
|
||||
|
||||
虽然如此,这个生成器确实已经可以正常使用。我用它来重新生成了一个这个网站,并将之前为 Hexo 写的主题应用其中。目前的版本是 ***v0.03*** 。项目仅仅是一个粗劣地模仿 Hexo 的产物,并没有融入很多我的思考,很多核心功能也只是调用别人写好的包,但是这次的代码仍然给我很多地启示,我发觉我开始接触到了很多软件工程的内容,同时对一个项目的整体规划有了一些了解。总之,这一次的项目开发还是很有意思的,之后的一段时间会着手于新的开发文档,并希望能在书写开发文档的过程中,思考更多新的功能。
|
||||
|
||||
最后,这是一个非常不完美的项目,里面的代码混乱不堪,复杂的逻辑结构几乎把我压倒。在多次重构和清理冗杂代码后,确实完成了我一开始定下的所有功能需求,但是这样的代码总归让人看了倒胃口,但也只能在之后的日子里慢慢修改完善啦,至少它没有夭折在漫长的开发期中。我确实是以最快的速度开发的,为的就是减少枯燥,目前看来确实如此,我在耐心消耗殆尽前完成了这个项目。
|
||||
23
content/posts/stop-maintaining-hsunr-december-projects.md
Normal file
23
content/posts/stop-maintaining-hsunr-december-projects.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
id: 60b0e668-dbaf-4464-aa7b-12ddf4375dd8
|
||||
slug: stop-maintaining-hsunr-december-projects
|
||||
title: 停止维护 Hsunr 和 December 项目
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-02-23T17:12:00+08:00"
|
||||
created_at: "2023-02-23T17:12:00+08:00"
|
||||
updated_at: "2026-01-07T18:11:01+08:00"
|
||||
---
|
||||
|
||||
## 事情的经过
|
||||
2023年2月28日,我的学生价腾讯服务器正式回复原价,一年800块的价格属实有些承受不起;
|
||||
|
||||
由于这两个项目都是搭载在服务器端的,服务器停止后近几年也不再有机会在服务器运行维护,从而发现问题,所以从现在开始停止维护 Hsunr和 December这两个项目。
|
||||
|
||||
## 转移至 Hexo
|
||||
原先基于 Hsunr 项目的博客系统转移到基于 Hexo 的静态博客中,由 Github 托管。在转移的过程中,顺带着写了一套 Hexo 的主题,比较用别人的主题总感觉不是很舒服。
|
||||
39
content/posts/storage-expansion.md
Normal file
39
content/posts/storage-expansion.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
id: a2cc5293-430c-4e0e-a415-f6e3d87126b1
|
||||
slug: storage-expansion
|
||||
title: 存储扩展
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2023-03-04T13:18:01+08:00"
|
||||
created_at: "2023-03-04T13:18:01+08:00"
|
||||
updated_at: "2026-01-07T18:11:07+08:00"
|
||||
---
|
||||
|
||||
在使用了两年的双系统后,逐渐感觉到了512G是远远不够的,但暂时没有更换硬盘的资金,所以,我计划分别购入256G和128G的SD卡作为系统的扩展。
|
||||
|
||||
### 速度测试
|
||||
|
||||
由于不是 SSD 硬盘,而且 SD 卡被设计出来主要就是静态的存储,没有类似主控的东西,所以无论是速度还是质量都是难堪大任的,但由于我平时并不怎么使用 windows , 所以尚能接受。
|
||||
|
||||
但为了保险起见,我将使用一张 64G 的 tf 卡测试其最小性能。
|
||||
|
||||
64G 的 tf 卡满足整体的使用,所以我购买了一张 128G 的 SD 卡作为虚拟机的硬盘,显然在速度方面它的能够满足我的使用的,那么自要做好备份工作就没问题了。
|
||||
|
||||
|
||||
|
||||
### 闪迪 256G 固态U盘
|
||||
|
||||
事实上在这个容量上,买一盘固态硬盘更好,同样的价格可以来到500G的容量,而且安全性肯定也是更高的,但是考虑到U盘的体积,所以,我更偏爱于这个U盘。
|
||||
|
||||
对于这个U盘,我可能有以下打算:
|
||||
|
||||
1.仍然保留双系统,这个固态U盘作为虚拟机硬盘使用。这个打算主要是考虑到SD卡的使用寿命并不安全,很可能在我工作时崩坏,这会导致严重的后果。而这个U盘显然更加的安全一些。
|
||||
|
||||
2.不再保留双系统,Linux 系统将独占整个512G的硬盘,同时分离128G出来给Windows虚拟机,之前购买的128G的SD卡作为资料存储卡,256G的固态U盘将安装为 Windows 系统。
|
||||
|
||||
我更喜欢第二种方案,因为它更安全可靠。
|
||||
27
content/posts/thinking.md
Normal file
27
content/posts/thinking.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
id: 189e3653-183f-49f5-b32d-fdc3372855a7
|
||||
slug: thinking
|
||||
title: 思考
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2022-11-26T16:54:03+08:00"
|
||||
created_at: "2022-11-26T16:54:03+08:00"
|
||||
updated_at: "2026-01-07T18:10:52+08:00"
|
||||
---
|
||||
|
||||
近日,全国范围内短时间爆发了多次冲突,郑州警民冲突,广州警民冲突,成都小区的自由宣言,乌鲁木齐楼层大火导致的本月25日夜的大游行,这些冲突在不同时间不同地点爆发,历史的潮流开始显现新的迹象。这些冲突的核心诉求是要求政府结束过于严格的防疫措施。
|
||||
|
||||
但可以看到的是,游行之中大部分人依旧还带着口罩,这说明三年来中国政府已经将新冠病毒的恐怖植入公民的内心之中,这是深刻而强烈的,即,使得绝大部分公民并非反对防疫,相反,他们普遍认同政府目前的防疫方向,但对他们滥用权利而导致防疫过度有不同程度的怨言。
|
||||
|
||||
积压的民意在游行中被宣泄出来,政府的暴力机器所面临地是更深刻的思想斗争,他们收到的命令是镇压一场游行、阻止一次叛乱,抓捕甚至杀死前方聚集的叛乱者,但不管怎么说,请相信它的威能和永远不要相信它的仁慈。
|
||||
|
||||
---
|
||||
|
||||
2022.11.27 追加。
|
||||
|
||||
昨晚,上海 乌鲁木齐路爆发了吊唁活动,与之而来的是新的大聚集事件,请永远小心。最近能看到新闻媒体也在尝试发出更多声音,这种禁锢已经套在他们身上太久了,那样的事件落在自己、家人、朋友身上,都将是一场重大的灾难。
|
||||
213
content/posts/v2ray-setup-configuration.md
Normal file
213
content/posts/v2ray-setup-configuration.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
---
|
||||
id: 0f3f971c-93e5-443e-83ba-b9a2986a4083
|
||||
slug: v2ray-setup-configuration
|
||||
title: V2ray 搭建与配置记录
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-07T09:30:00+08:00"
|
||||
created_at: "2023-05-22T12:34:43+08:00"
|
||||
updated_at: "2026-01-07T09:30:00+08:00"
|
||||
---
|
||||
|
||||
## 前言
|
||||
近来想纯净自己的ip,同时也想试着搭建 v2ray 服务供以自己代理使用,所以上两个星期便购买了一台 Vultr 的 VPS。出现了一些问题,同时也确实都得以解决了,这里将问题和解决办法列出:
|
||||
|
||||
### 1. 未做伪装的 ip 被封杀
|
||||
这是第一次尝试使用 vps 搭建 v2ray 服务,事实上非常的顺利,我确实完成了 v2ray 的搭建,但是第三天便遭到了中国政府的防火墙一记 ban ip 操作。现在想着这是极为危险的,但事情还得继续,问题自然是发生在我未对 v2ray 进行伪装,因为初次接触,所以对很多新的名词有恐惧心理,一开始便没有去尝试伪装操作,最终酿下错误,但是错误的本身其实也在推动着我技术的进步。
|
||||
|
||||
问题的解决方法是,首先通过 WebSocket+TLS+Web 完成对传输层流量伪装,原理就是通过 Nginx 搭建了一个真实的网站,分流一个特定的路由去转发 v2ray 的流量,其实正常的伪装到这里就结束了。但前面我说了,我的 VPS ip 被封杀了,其导致了我没办法在国内正常通过 ssh 访问到服务器,所以根据攻略我又加了一层伪装,我将我的域名名服务器交给 cloudflare 让其帮我把域名伪装成其他的ip,从而躲过封杀。
|
||||
|
||||
这样的后果也很明显,虽然更加安全了,但是代理速度确实也下来了。。。于是,我还是决定摧毁当前的 vps ,重新构建一个,Vulter 会重新分配一个 新的ip。
|
||||
|
||||
### 2. ChatGPT 封杀所有 vultr 的 ip
|
||||
如小标题所言,openai 确实封杀了所有来自 vultr 的 ip,但是这个问题不大,我还是通过 cloundflare 单独对这个域名伪装了 ip 得以成功使用chatgpt,这个在后文中会详细讲到。
|
||||
|
||||
## 配置 vps 和 v2ray
|
||||
|
||||
### 修改 hostname
|
||||
|
||||
这个对v2ray 没啥子用,只是我想改而已:)
|
||||
|
||||
```
|
||||
vim /etc/hostname
|
||||
|
||||
```
|
||||
|
||||
### 安装 git
|
||||
|
||||
```
|
||||
apt-get install git
|
||||
```
|
||||
|
||||
### 安装 zsh 和 ohmyzsh
|
||||
Linux 自带的 bash 不是那么地方便使用,所以换成 zsh 是个更好的选择。
|
||||
```
|
||||
apt-get install zsh
|
||||
sh -c "$(wget -O- https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
|
||||
```
|
||||
|
||||
### 安装 v2ray
|
||||
|
||||
#### 通过官方脚本自动安装 v2ray 本体
|
||||
确实有很多种方式安装 v2ray,我这里选择使用 `fhs-install-v2ray` 项目提供的安装脚本进行安装:
|
||||
主要是它能帮我自动创建一些文件:
|
||||
```
|
||||
installed: /usr/local/bin/v2ray
|
||||
installed: /usr/local/bin/v2ctl
|
||||
installed: /usr/local/share/v2ray/geoip.dat
|
||||
installed: /usr/local/share/v2ray/geosite.dat
|
||||
installed: /usr/local/etc/v2ray/config.json
|
||||
installed: /var/log/v2ray/
|
||||
installed: /var/log/v2ray/access.log
|
||||
installed: /var/log/v2ray/error.log
|
||||
installed: /etc/systemd/system/v2ray.service
|
||||
installed: /etc/systemd/system/v2ray@.service
|
||||
```
|
||||
|
||||
执行下面的程序脚本即可(目前时间是:2023-05-22,该脚本未来可能发生变动,以具体情况为准)
|
||||
此处贴出项目 README 文件:[README.zh]('https://github.com/v2fly/fhs-install-v2ray/blob/master/README.zh-Hans-CN.md')
|
||||
|
||||
```
|
||||
bash <(curl -L https://raw.githubusercontent.com/v2fly/fhs-install-v2ray/master/install-release.sh)
|
||||
```
|
||||
|
||||
执行完后,若无明显错误,即可通过 `systemctl start v2ray.service` 和 `systemctl status v2ray.service` 查看是否能正确启动。
|
||||
|
||||
#### 配置 v2ray 服务端
|
||||
无论是服务端亦或是客户端的配置,其实都非常简单,只需要将配置文件改动一些即可使用。
|
||||
以下是官方提供的服务端配置:
|
||||
```json
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"port": 10086, // 服务器监听端口
|
||||
"protocol": "vmess",
|
||||
"settings": {
|
||||
"clients": [
|
||||
{
|
||||
"id": "xxxxxxxxxxxxxxx"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"protocol": "freedom"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
在入门阶段,只需要了解 `inbounds` 中的 `port` 和 `id` 即可。
|
||||
- `port` 即是服务器的监听端口
|
||||
- `id` 可以理解为某种密钥一样的存在,在服务端与客户端交流时,需要通过这值进行辨别身份。可以使用 `cat /proc/sys/kernel/random/uuid` 指令生成这个 id。
|
||||
|
||||
> 服务器的配置中需要确保 id 和端口与客户端一致,就可以正常连接了。
|
||||
|
||||
最后重启一下 v2ray 服务即可:`systemctl restart v2ray.service` ,当然因为可能存在的将配置文件写错的问题,最好再查看一下有没有错误产生 `systemctl status v2ray.service`
|
||||
|
||||
没有问题后,即可开始配置 v2ray 客户端。
|
||||
|
||||
啊,最后注意让防火墙放开对应的端口,比如这里的10086端口
|
||||
```
|
||||
ufw allow 10086/tcp
|
||||
```
|
||||
### 配置 v2ray 客户端
|
||||
- Linux 平台可以使用 v2ray 程序(服务器所用的那个),它即可以当服务端也可以作为客户端使用
|
||||
- Android 平台可以使用 v2rayNG
|
||||
|
||||
无论是 android 还是 windows,其都有相应的图形界面,配置起来很简单,这里主要讲一下 v2ray 如何作为客户端使用。
|
||||
|
||||
以下贴出官方提供的客户端配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"inbounds": [
|
||||
{
|
||||
"port": 1080, // SOCKS 代理端口,在浏览器中需配置代理并指向这个端口
|
||||
"listen": "127.0.0.1",
|
||||
"protocol": "socks",
|
||||
"settings": {
|
||||
"udp": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"protocol": "vmess",
|
||||
"settings": {
|
||||
"vnext": [
|
||||
{
|
||||
"address": "server", // 服务器地址,请修改为你自己的服务器 ip 或域名
|
||||
"port": 10086, // 服务器端口
|
||||
"users": [
|
||||
{
|
||||
"id": "b831381d-6324-4d53-ad4f-8cda48b30811"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"protocol": "freedom",
|
||||
"tag": "direct"
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"domainStrategy": "IPOnDemand",
|
||||
"rules": [
|
||||
{
|
||||
"type": "field",
|
||||
"ip": [
|
||||
"geoip:private"
|
||||
],
|
||||
"outboundTag": "direct"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
虽然这看起来蛮多的,但需要修改的地方也就是 `outbounds` 里的 `address`、`port` 和 `id`
|
||||
- `address` 填入 vps 的 ip 地址亦或是域名
|
||||
- `port` 填入服务端中监听的那个地址
|
||||
- `id` 填入服务端的 id
|
||||
|
||||
这样一切弄完后,在配置一下 `socks_proxy` 的环境变量即可。
|
||||
```
|
||||
export socks_proxy=https://127.0.0.1:1080;"
|
||||
```
|
||||
当然也可以把 `http_proxy` 和 `https_proxy` 代理填入。
|
||||
|
||||
若是你想在浏览器使用:
|
||||
- firefox 相对方便,直接在设置中选择 socks5 代理,地址即填入配置文件中的:`127.0.0.1:1080`
|
||||
- chrome 可以去下载 名为 `SwitchyOmega` 的插件,也使用 sock5 配置即可
|
||||
|
||||
### 使用 WebSocket+TLS+Web 完成伪装
|
||||
此处先给出参考的文章引用,在文章末尾也会重复给出,感谢各位前辈的教程。
|
||||
- [V2Fly]('https://www.v2fly.org/guide/install.html')
|
||||
- [V2Ray+WebSocket+TLS+Web+Cloudflare教程]('https://whuwangyong.vercel.app/2023-03-18-v2ray-websocket-tls-web-cloudflare-guide/')
|
||||
- [linuxfunk]('https://linuxfunk.com/2017/10/31/v2ray-ws-tls-nginx%E5%AE%9E%E7%8E%B0%E5%8A%A0%E5%AF%86%E4%BC%A0%E8%BE%93/')
|
||||
- [V2Ray进阶指南:WSS组合配置(WebSocket + TLS + Nginx + CDN)]('https://cyfeng.science/2020/03/22/advanced-v2ray-with-wss/')
|
||||
|
||||
在完成了之前那些操作后,自然是可以正常使用 v2ray 的,但是非常容易被封杀(我的坚持了两天hhhhh),这也是我后来才得知道的 :(
|
||||
|
||||
总之,被封杀了我便意识到了伪装是极其重要的,以下先介绍一些概念名称,我刚开始接触这一部分便直接放弃了,但遗憾的是这一部分却又不得不学,GFW 太强啦,我们只能被迫去与之对抗。
|
||||
|
||||
#### TLS
|
||||
`TLS 加密`,TLS 中文叫 传输层安全性协议,以下是 wikipedia 的描述:
|
||||
> 将通信两方交换的资料做加密,保证两个应用间通信的保密性和可靠性,使客户与服务器应用之间的通信不被攻击者窃听
|
||||
|
||||
这是用来加密的,广为人知的 `HTTP` 在经过 TLS 加密后,成为了`HTTPS`
|
||||
|
||||
若是 v2ray 的流量没有被 TLS 加密,那么审查者便能根据数据内容分析出,这是违“法”的数据,而且代理流量源源不断地从固定 ip 发来,这样的流量同样是不正常的。
|
||||
|
||||
#### 网站前置伪装 与 websocket 协议
|
||||
仅用 TLS 加密依旧是不够的,因为流量不正常,这些大规模的流量因为代理的原因来去于同一个ip,这样的流量即使是加密的,也应该是有问题的。所以,需要再完成一次伪装,使用 Nginx 与 域名 搭建一个真实的网站,我们的客户端通过 websocket(ws)协议与这个用 Nginx 作为前端的网站进行数据交流,这样一来,我们的服务器就是正儿八经的网站服务器(事实也确实如此),而非代理服务器,在找到某种特征之前,不可能大面积封杀这样的正常服务器。
|
||||
|
||||
使用,我们现在的任务很简单,购买一个域名(可以是免费的二级域名),在我们的 VPS 中通过 Nginx 搭建一个真实但简单的网站,然后让客户端和服务器端通过ws协议交流,接着通过 Nginx 分流特定的路由。
|
||||
47
content/posts/why-i-like-mcdonalds.md
Normal file
47
content/posts/why-i-like-mcdonalds.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
id: fd9511d0-a4ef-473e-b922-d27faf785956
|
||||
slug: why-i-like-mcdonalds
|
||||
title: 我为什么喜欢吃麦当劳
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-14T17:26:01+08:00"
|
||||
created_at: "2025-11-07T09:47:00+08:00"
|
||||
updated_at: "2026-01-14T17:26:02+08:00"
|
||||
---
|
||||
|
||||
昨天晚上我在床上刷视频的时候,偶然看到了这样一组漫画,潦草的涂鸦画风,但于我而言确能深深地感受到作者想表达的,在痛苦中煎熬而想宣泄出来的感情。
|
||||
|
||||

|
||||
|
||||
学生时代,麦当劳的汉堡对我来说是一种奢侈。那时我常常困惑:为什么那么贵的东西,店里却总是坐满了人?
|
||||
|
||||
没想到,工作以后,我也慢慢喜欢上了麦当劳。
|
||||
|
||||
一方面,出租屋周围的饭菜总带着浓重的油味,让人提不起胃口;另一方面,我也突然发现,麦当劳并没有想象中那么贵。
|
||||
|
||||
那天晚上,我走进一家麦当劳。灯火辉煌,一切都那么明亮,空气里弥漫着麦乐块和汉堡的香气。
|
||||
|
||||
当时的我在处理着公司的一个极为棘手的项目,压力大到无法想象,每天都是折磨与痛苦,难以推进的进展,未来悔暗的道路,每走一步都觉得前方是绝路。因为项目加班,晚上也实在没时间吃什么,也没有胃口,回家的地铁出口正好有一家麦当劳,于是萌生了想去吃吃看的想法。
|
||||
|
||||
我找了个边边角角的地方坐下,扫了桌上的二维码,点了两份 1+1 套餐,当时的我惊讶地发现,麦当劳没有自己想象中的那么昂贵,不久取到餐便吃了起来。第一个汉堡吃的是麦香鸡堡,入口的感觉很平淡,怎么去描述呢?那是一种干净的枯燥感,这个形容很奇怪,就是一种不油不腻、面包裹挟着燥热的鸡块的感觉,咀嚼时少量的肉汁流出,但总体仍是干燥,继续吃了几口,这几口中夹杂着生菜和白色的酱汁,确实就是记忆中汉堡的味道。汉堡并不大,其实几口也就吃完了。吃了几根薯条,又喝了几口可乐,我便继续下一个汉堡——双层吉士汉堡。
|
||||
|
||||
双层吉士汉堡看起来很漂亮:两片芝士夹着两片小肉排,再被柔软的面包包裹着。我喜欢黄色,尤其是那种明亮的芝士黄。咬下第一口,酸黄瓜和番茄酱的味道立刻冲上舌尖,接着是煎烤牛肉与芝士的香气。我掀开面包看了看,果然——酸黄瓜、番茄酱、芝士,这组合让我忽然想起海绵宝宝的蟹黄堡。
|
||||
|
||||
当然,海绵宝宝是后来的,毕竟一定是先有麦当劳,才有那只做汉堡,喜欢上班的海绵。
|
||||
|
||||
那幅漫画里的人,点了昂贵的一餐,把东西往自己肚子里塞,想用饱腹感换取一点快乐,最后却吃到想吐。
|
||||
|
||||
我懂他在做什么。
|
||||
|
||||
我懂他为什么感觉到如此幸福,我也懂他为什么在哭。
|
||||
|
||||
是的,麦当劳和别的餐厅不一样。它真的救过我。我被压力与痛苦折磨、觉得人生像死路一样的时候,有这样一个地方让我坐下喘息,让我吃饱,周围是热热闹闹的,没有人会来指责我,我安静地吃着,我重新活了过来。
|
||||
|
||||
项目结束,我没有搞砸,一切都是好的,但是我太害怕了,即使已经一年了,我还在害怕。在那之后,我总是喜欢留意工作或者生活周围的麦当劳店铺,在心里默默标记下他们的位置。后面遇到一些郁闷的事情的时候,我也会戴上耳机,徒步走到最近的麦当劳,点一份汉堡、薯条和可乐。安静地坐着,看视频、发呆,二十分钟、半个小时。喝完最后一口可乐,收拾好包装袋,拍一拍衣服,起身离开。
|
||||
|
||||
平时不知道吃什么的时候,也总是会去麦当劳坐一坐,点上一个汉堡和一杯可乐。
|
||||
39
content/posts/wuzhou-old-british-consulate.md
Normal file
39
content/posts/wuzhou-old-british-consulate.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
id: 9c8e14a5-960d-4850-a1c9-e2f359d4f4bb
|
||||
slug: wuzhou-old-british-consulate
|
||||
title: 梧州旧英国领事馆往事
|
||||
summary: ""
|
||||
status: published
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: manual
|
||||
slug_locked: true
|
||||
published_at: "2026-01-14T17:36:55+08:00"
|
||||
created_at: "2025-10-09T14:59:00+08:00"
|
||||
updated_at: "2026-01-14T17:36:56+08:00"
|
||||
---
|
||||
|
||||
今年十一国庆八天长假,国内外景点人潮汹涌,我索性回梧州,与朋友逛逛街、聚聚餐,在家休息恢复元气。
|
||||
|
||||
梧州城内保留着浓厚的民国气息,作为近代通商口岸,这里曾是东西文化交汇之地。并随着天主教、基督教的传播,城区陆续建立起小型教堂与传教站,至今在河东仍可见部分完好的建筑。
|
||||
|
||||

|
||||
|
||||
英国领事馆于1897年开设,作为管理西江贸易的据点。其位于浔江和西江交汇处的白鹤山(珠山)丘顶。这片土地被划为英国领土,竖立界碑和“游人不准登山”牌子。
|
||||
|
||||
从网上找到的一些胶片照片 
|
||||
|
||||

|
||||
|
||||
可惜的是我忘记拍摄英国领事馆的正面的,主要也是当时人有些多,如今相机里只有侧面的照片,可惜可惜。
|
||||
|
||||

|
||||
|
||||
辛亥革命后,民族主义与反帝思潮兴起。1919年爆发“白鹤山事件”,英领馆与中国驻军冲突,英军舰示威,最终中方被迫道歉赔款。1926年“五卅运动”周年纪念日,民众包围领事馆,本地雇员辞职,英国领事仓皇逃港。1928年,广西省主席黄绍弘与英国驻穗领事谈判,以25,000港元赎回土地,并竖立“还我河山”碑。
|
||||
|
||||
世界上许多国家都有独立日,它们背后都与殖民史相关。殖民帝国的扩张带来掠夺与压迫,也在血与火中孕育了民族意识与现代文明的种子。
|
||||
|
||||
英国领事馆前的雕塑: 
|
||||
|
||||
登上白鹤楼,可俯瞰河东  两江汇流处,可以看到明显的河水清浊对撞,一清一浊、泾渭分明,本地人称为 “鸳鸯江” , 图中的这座桥便是鸳鸯桥,不过小时候因为这种桥的颜色,我其实一直称它为 “彩虹桥” 
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
id: "7244cd44db756b30362c585703ded1fb"
|
||||
slug: "xi-huan-ni"
|
||||
title: "喜欢你"
|
||||
summary: ""
|
||||
status: "draft"
|
||||
tags: ["go"]
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: "ai"
|
||||
slug_locked: false
|
||||
published_at: null
|
||||
created_at: "2026-05-28T13:23:35+08:00"
|
||||
updated_at: "2026-05-28T13:23:35+08:00"
|
||||
---
|
||||
|
||||
Write your post here.
|
||||
30
frontend/admin/README.md
Normal file
30
frontend/admin/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Osaet Admin
|
||||
|
||||
Angular admin UI for the Osaet Gin API.
|
||||
|
||||
## Development
|
||||
|
||||
Start the Go admin API first:
|
||||
|
||||
```bash
|
||||
cd /home/yarnom/Codes/osaet/backend
|
||||
DATABASE_URL='postgres://yarnom:数据库密码@10.66.0.30:5432/osaet?sslmode=disable' \
|
||||
go run ./cmd/osaet-admin serve
|
||||
```
|
||||
|
||||
Install dependencies and start Angular:
|
||||
|
||||
```bash
|
||||
cd /home/yarnom/Codes/osaet/frontend/admin
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:4200/
|
||||
```
|
||||
|
||||
The Angular dev server proxies `/api`, `/healthz`, and `/readyz` to
|
||||
`http://127.0.0.1:8080`.
|
||||
62
frontend/admin/angular.json
Normal file
62
frontend/admin/angular.json
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"admin": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "osaet",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/admin",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [],
|
||||
"styles": ["src/styles.css"],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "admin:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "admin:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": "1f777743-c088-4a64-b88c-12e453da36c1"
|
||||
}
|
||||
}
|
||||
13966
frontend/admin/package-lock.json
generated
Normal file
13966
frontend/admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
frontend/admin/package.json
Normal file
28
frontend/admin/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@osaet/admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "ng serve --host 127.0.0.1 --port 4200 --proxy-config proxy.conf.json",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.0.0",
|
||||
"@angular/common": "^20.0.0",
|
||||
"@angular/compiler": "^20.0.0",
|
||||
"@angular/core": "^20.0.0",
|
||||
"@angular/forms": "^20.0.0",
|
||||
"@angular/platform-browser": "^20.0.0",
|
||||
"@angular/router": "^20.0.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"tslib": "^2.8.0",
|
||||
"zone.js": "^0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/cli": "^20.0.0",
|
||||
"@angular/compiler-cli": "^20.0.0",
|
||||
"@angular-devkit/build-angular": "^20.0.0",
|
||||
"typescript": "~5.8.0"
|
||||
}
|
||||
}
|
||||
17
frontend/admin/proxy.conf.json
Normal file
17
frontend/admin/proxy.conf.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"/api": {
|
||||
"target": "http://127.0.0.1:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/healthz": {
|
||||
"target": "http://127.0.0.1:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/readyz": {
|
||||
"target": "http://127.0.0.1:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
117
frontend/admin/src/app/admin-api.service.ts
Normal file
117
frontend/admin/src/app/admin-api.service.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import {
|
||||
AssetResponse,
|
||||
BuildJobResponse,
|
||||
LoginResponse,
|
||||
DeletePostResponse,
|
||||
PostInput,
|
||||
PostResponse,
|
||||
PostStatus,
|
||||
PostsResponse,
|
||||
SlugResponse
|
||||
} from './models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/admin';
|
||||
|
||||
me() {
|
||||
return this.http.get<{ user: LoginResponse['user'] }>(`${this.baseUrl}/me`, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
login(username: string, password: string) {
|
||||
return this.http.post<LoginResponse>(
|
||||
`${this.baseUrl}/login`,
|
||||
{ username, password },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this.http.post<{ ok: boolean }>(`${this.baseUrl}/logout`, {}, { withCredentials: true });
|
||||
}
|
||||
|
||||
listPosts(status: PostStatus | '', limit?: number, offset?: number) {
|
||||
let params = new HttpParams();
|
||||
if (status) {
|
||||
params = params.set('status', status);
|
||||
}
|
||||
if (limit) {
|
||||
params = params.set('limit', String(limit));
|
||||
}
|
||||
if (offset) {
|
||||
params = params.set('offset', String(offset));
|
||||
}
|
||||
return this.http.get<PostsResponse>(`${this.baseUrl}/posts`, {
|
||||
params,
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
getPost(id: string) {
|
||||
return this.http.get<PostResponse>(`${this.baseUrl}/posts/${id}`, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
createPost(input: PostInput) {
|
||||
return this.http.post<PostResponse>(`${this.baseUrl}/posts`, input, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
updatePost(id: string, input: PostInput) {
|
||||
return this.http.put<PostResponse>(`${this.baseUrl}/posts/${id}`, input, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
deletePost(id: string) {
|
||||
return this.http.delete<DeletePostResponse>(`${this.baseUrl}/posts/${id}`, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
publishPost(id: string) {
|
||||
return this.http.post<PostResponse & BuildJobResponse>(
|
||||
`${this.baseUrl}/posts/${id}/publish`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
buildPost(id: string) {
|
||||
return this.http.post<BuildJobResponse>(
|
||||
`${this.baseUrl}/posts/${id}/build`,
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
getBuildJob(id: string) {
|
||||
return this.http.get<BuildJobResponse>(`${this.baseUrl}/build-jobs/${id}`, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
uploadAsset(file: File) {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
return this.http.post<AssetResponse>(`${this.baseUrl}/assets`, body, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
generateSlug(title: string, summary: string, postId?: string) {
|
||||
return this.http.post<SlugResponse>(
|
||||
`${this.baseUrl}/slug`,
|
||||
{ title, summary, postId },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
807
frontend/admin/src/app/app.component.css
Normal file
807
frontend/admin/src/app/app.component.css
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 0.7em;
|
||||
padding: 0.72em 1.15em;
|
||||
background: #243b53;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1c3147;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.7em;
|
||||
background: #fff;
|
||||
color: #2f4a63;
|
||||
cursor: pointer;
|
||||
padding: 0.72em 1.15em;
|
||||
}
|
||||
|
||||
.upload-button:hover {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.upload-button input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
padding: 0.45em 0.65em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e8e5df;
|
||||
border-radius: 0.7em;
|
||||
background: #fff;
|
||||
color: #232428;
|
||||
padding: 0.8em 0.95em;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: #9aa9b6;
|
||||
box-shadow: 0 0 0 0.14em rgb(36 59 83 / 5%);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.5em;
|
||||
color: #55575d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.loading-shell,
|
||||
.shell {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #777b82;
|
||||
}
|
||||
|
||||
.login-view {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(90vw, 27em);
|
||||
display: grid;
|
||||
gap: 1.2em;
|
||||
border: 1px solid #eee;
|
||||
padding: 2em;
|
||||
border-radius: 0.9em;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.1em 25%;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
min-width: 0;
|
||||
color: #777b82;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.breadcrumb a,
|
||||
.breadcrumb button {
|
||||
border-radius: 0.65em;
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
padding: 0.35em 0.45em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover,
|
||||
.breadcrumb button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.breadcrumb span:last-child {
|
||||
overflow: hidden;
|
||||
color: #3d3d3f;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.user-menu summary {
|
||||
list-style: none;
|
||||
border-radius: 0.75em;
|
||||
color: #2f4a63;
|
||||
cursor: pointer;
|
||||
padding: 0.55em 0.8em;
|
||||
}
|
||||
|
||||
.user-menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-menu summary::after {
|
||||
content: "⌄";
|
||||
margin-left: 0.45em;
|
||||
color: #777b82;
|
||||
}
|
||||
|
||||
.user-menu summary:hover {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.user-menu-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.45em);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
min-width: 8em;
|
||||
display: grid;
|
||||
gap: 0.2em;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0.75em;
|
||||
background: #fff;
|
||||
padding: 0.45em;
|
||||
}
|
||||
|
||||
.user-menu-panel button {
|
||||
width: 100%;
|
||||
border-radius: 0.55em;
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
padding: 0.6em 0.8em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-menu-panel button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.list-view,
|
||||
.editor-view {
|
||||
width: 50%;
|
||||
margin: 0 auto;
|
||||
padding: 3em 0 4em;
|
||||
}
|
||||
|
||||
.feedback-toast {
|
||||
position: fixed;
|
||||
top: 1.2em;
|
||||
right: 1.2em;
|
||||
z-index: 30;
|
||||
width: min(92vw, 22em);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
border: 1px solid #eee;
|
||||
border-left: 0.28em solid #2f7d4f;
|
||||
border-radius: 0.75em;
|
||||
background: #fff;
|
||||
box-shadow: 0 0.25em 1.2em rgb(35 36 40 / 8%);
|
||||
color: #3d3d3f;
|
||||
padding: 0.9em 1em;
|
||||
}
|
||||
|
||||
.feedback-toast.info {
|
||||
border-left-color: #2f4a63;
|
||||
}
|
||||
|
||||
.feedback-toast.error {
|
||||
border-left-color: #9b332c;
|
||||
}
|
||||
|
||||
.feedback-toast div {
|
||||
display: grid;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.feedback-toast strong {
|
||||
color: #232428;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.feedback-toast span {
|
||||
color: #676b72;
|
||||
font-size: 0.86em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.feedback-toast button {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #777b82;
|
||||
padding: 0.25em 0.4em;
|
||||
}
|
||||
|
||||
.feedback-toast button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.page-heading h1,
|
||||
.editor-head h1,
|
||||
.login-panel h1 {
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
}
|
||||
|
||||
.page-actions select,
|
||||
.page-actions button {
|
||||
width: auto;
|
||||
min-height: 2.55em;
|
||||
border-radius: 0.65em;
|
||||
font-size: 0.9em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.page-actions select {
|
||||
min-width: 8em;
|
||||
padding: 0 2.2em 0 0.9em;
|
||||
}
|
||||
|
||||
.page-actions button {
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
.editor-head h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35em;
|
||||
color: #8b8175;
|
||||
font-size: 0.78em;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dirty-dot {
|
||||
width: 0.45em;
|
||||
height: 0.45em;
|
||||
border-radius: 50%;
|
||||
background: #a86a2d;
|
||||
}
|
||||
|
||||
.post-table {
|
||||
--post-table-columns: minmax(0, 1.5fr) minmax(8em, 0.8fr) 6em 10em 5.6em;
|
||||
|
||||
display: grid;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.post-table-head,
|
||||
.post-table-row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: var(--post-table-columns);
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0.9em 0.75em;
|
||||
}
|
||||
|
||||
.post-table-head {
|
||||
color: #777b82;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.post-table-head span {
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.post-table-row {
|
||||
background: #fff;
|
||||
color: #232428;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.post-table-row:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.post-row-title {
|
||||
overflow: hidden;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-row-tags,
|
||||
.post-table-row > span {
|
||||
color: #777b82;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.table-action {
|
||||
color: #2f4a63;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-action:hover {
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.table-action.danger {
|
||||
color: #9b332c;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
margin: 3em 0;
|
||||
color: #777b82;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45em;
|
||||
margin-top: 2em;
|
||||
color: #6d7179;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.page-numbers {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45em;
|
||||
}
|
||||
|
||||
.page-numbers {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
min-width: 2.2em;
|
||||
border-radius: 0.55em;
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
padding: 0.45em 0.65em;
|
||||
}
|
||||
|
||||
.page-number:hover,
|
||||
.page-number.active {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.editor-form {
|
||||
display: grid;
|
||||
gap: 1.2em;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0.8em;
|
||||
background: #fff;
|
||||
padding: 0.35em;
|
||||
box-shadow: 0 0.08em 0.35em rgb(35 36 40 / 4%);
|
||||
}
|
||||
|
||||
.editor-actions button,
|
||||
.editor-actions .upload-button {
|
||||
min-height: 2.45em;
|
||||
border-radius: 0.62em;
|
||||
padding: 0 0.9em;
|
||||
font-size: 0.88em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-actions .upload-button,
|
||||
.editor-actions .save-button,
|
||||
.editor-actions .build-button {
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
}
|
||||
|
||||
.editor-actions .upload-button:hover,
|
||||
.editor-actions .save-button:hover,
|
||||
.editor-actions .build-button:hover {
|
||||
background: #243b53;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fields-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.slug-control {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slug-control input {
|
||||
padding-right: 3.05em;
|
||||
}
|
||||
|
||||
.slug-ai-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.35em;
|
||||
width: 2.25em;
|
||||
height: 2.25em;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 0.62em;
|
||||
background: #fff;
|
||||
color: #2f4a63;
|
||||
padding: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.slug-ai-button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.slug-ai-button span {
|
||||
display: block;
|
||||
font-size: 1.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wide-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.body-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2em;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0.7em;
|
||||
padding: 0.2em;
|
||||
}
|
||||
|
||||
.mode-switch button {
|
||||
border-radius: 0.55em;
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
padding: 0.45em 0.7em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.mode-switch button:hover,
|
||||
.mode-switch button.active {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.markdown-workspace {
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.markdown-workspace.split {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.body-field textarea,
|
||||
.markdown-preview {
|
||||
min-height: 48vh;
|
||||
}
|
||||
|
||||
.body-field textarea {
|
||||
font-family:
|
||||
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.markdown-preview {
|
||||
overflow: auto;
|
||||
border: 1px solid #e8e5df;
|
||||
border-radius: 0.7em;
|
||||
color: #3d3d3f;
|
||||
font-size: 1em;
|
||||
line-height: 1.75;
|
||||
padding: 1em 1.1em;
|
||||
}
|
||||
|
||||
.markdown-preview :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-preview :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-preview h1,
|
||||
.markdown-preview h2,
|
||||
.markdown-preview h3 {
|
||||
color: #232428;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.markdown-preview a {
|
||||
color: #2f4a63;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.22em;
|
||||
}
|
||||
|
||||
.markdown-preview img {
|
||||
display: block;
|
||||
max-width: min(100%, 42em);
|
||||
border-radius: 0.7em;
|
||||
margin: 1.2em auto;
|
||||
}
|
||||
|
||||
.markdown-preview pre {
|
||||
overflow: auto;
|
||||
border-radius: 0.7em;
|
||||
background: #20242b;
|
||||
color: #f2f2ee;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.markdown-preview code {
|
||||
border-radius: 0.35em;
|
||||
background: #f3f4f6;
|
||||
padding: 0.12em 0.32em;
|
||||
}
|
||||
|
||||
.markdown-preview pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 1.4em;
|
||||
margin: 0;
|
||||
color: #7b4f27;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.editor-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.autosave-status {
|
||||
color: #777b82;
|
||||
font-size: 0.82em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.build-panel {
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0.8em;
|
||||
background: #fbfaf7;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.build-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.build-panel h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.build-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.build-meta div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.build-meta dt {
|
||||
color: #777b82;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.build-meta dd {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0.25em 0 0;
|
||||
color: #35383f;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.build-error {
|
||||
margin: 0;
|
||||
color: #9b332c;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.build-log {
|
||||
max-height: 22em;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border-radius: 0.7em;
|
||||
background: #20242b;
|
||||
color: #f2f2ee;
|
||||
padding: 1em;
|
||||
font-size: 0.84em;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.topbar {
|
||||
padding-inline: 8%;
|
||||
}
|
||||
|
||||
.list-view,
|
||||
.editor-view {
|
||||
width: 84%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar,
|
||||
.page-heading,
|
||||
.editor-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 1em 5%;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.list-view,
|
||||
.editor-view {
|
||||
width: 90%;
|
||||
padding-top: 2em;
|
||||
}
|
||||
|
||||
.post-table-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.post-table-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.35em;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.fields-grid,
|
||||
.build-meta,
|
||||
.markdown-workspace.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.editor-actions,
|
||||
.editor-status-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.editor-actions button,
|
||||
.editor-actions .upload-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.autosave-status {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.body-label-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
310
frontend/admin/src/app/app.component.html
Normal file
310
frontend/admin/src/app/app.component.html
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
@if (loading) {
|
||||
<main class="loading-shell">载入中</main>
|
||||
} @else if (!user) {
|
||||
<main class="login-view">
|
||||
<form class="login-panel" (ngSubmit)="login()">
|
||||
<div>
|
||||
<p class="eyebrow">Osaet Admin</p>
|
||||
<h1>登录后台</h1>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
用户名
|
||||
<input name="username" autocomplete="username" [(ngModel)]="loginUsername" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
[(ngModel)]="loginPassword"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit">登录</button>
|
||||
<p class="message">{{ loginMessage }}</p>
|
||||
</form>
|
||||
</main>
|
||||
} @else {
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<nav class="breadcrumb" aria-label="面包屑">
|
||||
<a href="/">首页</a>
|
||||
<span>/</span>
|
||||
@if (view === 'list') {
|
||||
<span>文章</span>
|
||||
} @else {
|
||||
<button type="button" (click)="backToList()">文章</button>
|
||||
<span>/</span>
|
||||
<span>{{ draft.title || '新文章' }}</span>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<details class="user-menu">
|
||||
<summary>{{ user.username }}</summary>
|
||||
<div class="user-menu-panel">
|
||||
<button type="button" (click)="logout()">退出</button>
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
|
||||
@if (feedback) {
|
||||
<aside
|
||||
class="feedback-toast"
|
||||
[class.info]="feedback.tone === 'info'"
|
||||
[class.error]="feedback.tone === 'error'"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ feedback.title }}</strong>
|
||||
<span>{{ feedback.message }}</span>
|
||||
</div>
|
||||
<button type="button" aria-label="关闭提示" (click)="closeFeedback()">x</button>
|
||||
</aside>
|
||||
}
|
||||
|
||||
@if (view === 'list') {
|
||||
<section class="list-view">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Posts</p>
|
||||
<h1>文章管理</h1>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<select aria-label="文章状态" [(ngModel)]="statusFilter" (change)="changeStatusFilter()">
|
||||
<option value="">全部</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="archived">归档</option>
|
||||
</select>
|
||||
<button type="button" (click)="newPost()">新文章</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-table">
|
||||
@if (posts.length === 0) {
|
||||
<p class="empty-message">暂无文章</p>
|
||||
} @else {
|
||||
<div class="post-table-head">
|
||||
<span>标题</span>
|
||||
<span>标签</span>
|
||||
<span>状态</span>
|
||||
<span>更新时间</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
@for (post of posts; track post.id) {
|
||||
<div
|
||||
class="post-table-row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="selectPost(post.id)"
|
||||
>
|
||||
<span class="post-row-title">{{ post.title || '未命名' }}</span>
|
||||
<span class="post-row-tags">{{ tagText(post) }}</span>
|
||||
<span>{{ statusText(post.status) }}</span>
|
||||
<span>{{ formatDate(post.updatedAt) }}</span>
|
||||
<span class="row-actions">
|
||||
<span class="table-action" role="button" tabindex="0" (click)="editPost(post.id, $event)">
|
||||
编辑
|
||||
</span>
|
||||
<span class="table-action danger" role="button" tabindex="0" (click)="deletePost(post, $event)">
|
||||
删除
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<nav class="pagination" aria-label="文章分页">
|
||||
<button type="button" class="link-button" [disabled]="page <= 1" (click)="previousPage()">
|
||||
← 上一页
|
||||
</button>
|
||||
<span class="page-numbers">
|
||||
<button
|
||||
type="button"
|
||||
class="page-number"
|
||||
*ngFor="let pageNumber of pageNumbers"
|
||||
[class.active]="pageNumber === page"
|
||||
(click)="goToPage(pageNumber)"
|
||||
>
|
||||
{{ pageNumber }}
|
||||
</button>
|
||||
</span>
|
||||
<button type="button" class="link-button" [disabled]="page >= totalPages" (click)="nextPage()">
|
||||
下一页 →
|
||||
</button>
|
||||
</nav>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="editor-view">
|
||||
<form class="editor-form" (ngSubmit)="savePost()">
|
||||
<div class="editor-head">
|
||||
<div>
|
||||
<p class="eyebrow">{{ currentPost ? '版本 ' + currentPost.version : '新文章' }}</p>
|
||||
<h1>
|
||||
{{ draft.title || '开始写作' }}
|
||||
@if (hasUnsavedChanges()) {
|
||||
<span class="dirty-dot" title="未保存"></span>
|
||||
}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<label class="upload-button">
|
||||
{{ uploadingAsset ? '上传中' : '上传图片' }}
|
||||
<input type="file" accept="image/*" [disabled]="uploadingAsset" (change)="uploadAsset($event)" />
|
||||
</label>
|
||||
<button type="submit" class="save-button" [disabled]="saving">{{ saving ? '保存中' : '保存' }}</button>
|
||||
<button type="button" class="build-button" (click)="buildPost()">构建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fields-grid">
|
||||
<label>
|
||||
标题
|
||||
<input name="title" required [(ngModel)]="draft.title" (input)="onTitleInput()" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Slug
|
||||
<span class="slug-control">
|
||||
<input name="slug" required [(ngModel)]="draft.slug" (input)="onSlugInput()" />
|
||||
<button
|
||||
type="button"
|
||||
class="slug-ai-button"
|
||||
[disabled]="generatingSlug"
|
||||
[attr.aria-label]="generatingSlug ? '正在生成 Slug' : 'AI 生成 Slug'"
|
||||
[title]="generatingSlug ? '正在生成 Slug' : 'AI 生成 Slug'"
|
||||
(click)="generateSlug()"
|
||||
>
|
||||
<span aria-hidden="true">✦</span>
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
状态
|
||||
<select name="status" [(ngModel)]="draft.status" (change)="onDraftInput()">
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="archived">归档</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
创建时间
|
||||
<input
|
||||
name="createdAt"
|
||||
type="datetime-local"
|
||||
[(ngModel)]="draft.createdAt"
|
||||
(input)="onDraftInput()"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
封面
|
||||
<input name="cover" [(ngModel)]="draft.cover" (input)="onDraftInput()" />
|
||||
</label>
|
||||
|
||||
<label class="wide-field">
|
||||
标签
|
||||
<input
|
||||
name="tags"
|
||||
placeholder="用逗号分隔,例如:生活, 技术"
|
||||
[(ngModel)]="tagsText"
|
||||
(input)="onDraftInput()"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
摘要
|
||||
<textarea
|
||||
name="summary"
|
||||
rows="3"
|
||||
[(ngModel)]="draft.summary"
|
||||
(input)="onDraftInput()"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="body-field">
|
||||
<span class="body-label-row">
|
||||
正文 Markdown
|
||||
<span class="mode-switch" role="group" aria-label="编辑模式">
|
||||
<button type="button" [class.active]="editorMode === 'edit'" (click)="setEditorMode('edit')">
|
||||
编辑
|
||||
</button>
|
||||
<button type="button" [class.active]="editorMode === 'preview'" (click)="setEditorMode('preview')">
|
||||
预览
|
||||
</button>
|
||||
<button type="button" [class.active]="editorMode === 'split'" (click)="setEditorMode('split')">
|
||||
分栏
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="markdown-workspace" [class.split]="editorMode === 'split'">
|
||||
@if (editorMode !== 'preview') {
|
||||
<textarea
|
||||
#bodyTextarea
|
||||
name="bodyMarkdown"
|
||||
spellcheck="false"
|
||||
[(ngModel)]="draft.bodyMarkdown"
|
||||
(input)="onDraftInput()"
|
||||
></textarea>
|
||||
}
|
||||
|
||||
@if (editorMode !== 'edit') {
|
||||
<article class="markdown-preview" [innerHTML]="previewHtml"></article>
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="editor-status-row">
|
||||
<p class="message">{{ editorMessage }}</p>
|
||||
<span class="autosave-status">{{ autosaveStatus }}</span>
|
||||
</div>
|
||||
|
||||
@if (currentBuildJob) {
|
||||
<section class="build-panel">
|
||||
<div class="build-panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Build Job</p>
|
||||
<h3>{{ buildStatusText(currentBuildJob.status) }}</h3>
|
||||
</div>
|
||||
<button type="button" class="link-button" (click)="toggleBuildLog()">
|
||||
{{ showBuildLog ? '收起日志' : '查看日志' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="build-meta">
|
||||
<div>
|
||||
<dt>ID</dt>
|
||||
<dd>{{ currentBuildJob.id }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>开始</dt>
|
||||
<dd>{{ formatDate(currentBuildJob.startedAt) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>结束</dt>
|
||||
<dd>{{ formatDate(currentBuildJob.finishedAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@if (currentBuildJob.error) {
|
||||
<p class="build-error">{{ currentBuildJob.error }}</p>
|
||||
}
|
||||
|
||||
@if (showBuildLog) {
|
||||
<pre class="build-log">{{ currentBuildJob.log || '暂无日志' }}</pre>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
}
|
||||
790
frontend/admin/src/app/app.component.ts
Normal file
790
frontend/admin/src/app/app.component.ts
Normal file
|
|
@ -0,0 +1,790 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Subscription, catchError, firstValueFrom, interval, of, switchMap, takeWhile } from 'rxjs';
|
||||
|
||||
import { AdminApiService } from './admin-api.service';
|
||||
import { BuildJob, Post, PostInput, PostStatus, User } from './models';
|
||||
|
||||
type FeedbackTone = 'success' | 'info' | 'error';
|
||||
|
||||
@Component({
|
||||
selector: 'osaet-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.css'
|
||||
})
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(AdminApiService);
|
||||
@ViewChild('bodyTextarea') private bodyTextarea?: ElementRef<HTMLTextAreaElement>;
|
||||
|
||||
user: User | null = null;
|
||||
posts: Post[] = [];
|
||||
currentPost: Post | null = null;
|
||||
statusFilter: PostStatus | '' = '';
|
||||
view: 'list' | 'editor' = 'list';
|
||||
editorMode: 'edit' | 'preview' | 'split' = 'edit';
|
||||
page = 1;
|
||||
readonly pageSize = 12;
|
||||
totalPosts = 0;
|
||||
|
||||
loginUsername = 'yarnom';
|
||||
loginPassword = '';
|
||||
loginMessage = '';
|
||||
editorMessage = '';
|
||||
feedback: { title: string; message: string; tone: FeedbackTone } | null = null;
|
||||
currentBuildJob: BuildJob | null = null;
|
||||
showBuildLog = false;
|
||||
loading = true;
|
||||
saving = false;
|
||||
uploadingAsset = false;
|
||||
generatingSlug = false;
|
||||
autosaveStatus = '未修改';
|
||||
lastAutosavedAt: Date | null = null;
|
||||
|
||||
draft = this.emptyDraft();
|
||||
tagsText = '';
|
||||
private savedSnapshot = '';
|
||||
private readonly autosaveIntervalMs = 12000;
|
||||
private autosaveSubscription: Subscription | null = null;
|
||||
private feedbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
void this.bootstrap();
|
||||
this.autosaveSubscription = interval(this.autosaveIntervalMs).subscribe(() => {
|
||||
void this.autosave();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.autosaveSubscription?.unsubscribe();
|
||||
if (this.feedbackTimer) {
|
||||
clearTimeout(this.feedbackTimer);
|
||||
}
|
||||
}
|
||||
|
||||
async bootstrap() {
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.me());
|
||||
this.user = response.user;
|
||||
await this.loadPosts();
|
||||
} catch {
|
||||
this.user = null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async login() {
|
||||
this.loginMessage = '';
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.login(this.loginUsername.trim(), this.loginPassword));
|
||||
this.user = response.user;
|
||||
this.loginPassword = '';
|
||||
await this.loadPosts();
|
||||
this.newPost();
|
||||
} catch (error) {
|
||||
this.loginMessage = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
if (!this.confirmDiscard()) {
|
||||
return;
|
||||
}
|
||||
await firstValueFrom(this.api.logout().pipe(catchError(() => of({ ok: true }))));
|
||||
this.user = null;
|
||||
this.posts = [];
|
||||
this.currentPost = null;
|
||||
this.draft = this.emptyDraft();
|
||||
this.tagsText = '';
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
|
||||
async loadPosts() {
|
||||
const response = await firstValueFrom(
|
||||
this.api.listPosts(this.statusFilter, this.pageSize, (this.page - 1) * this.pageSize)
|
||||
);
|
||||
this.posts = response.posts ?? [];
|
||||
this.totalPosts = response.total ?? 0;
|
||||
if (this.page > this.totalPages) {
|
||||
this.page = this.totalPages;
|
||||
await this.loadPosts();
|
||||
}
|
||||
}
|
||||
|
||||
async selectPost(id: string) {
|
||||
if (!this.confirmDiscard()) {
|
||||
return;
|
||||
}
|
||||
const response = await firstValueFrom(this.api.getPost(id));
|
||||
this.currentPost = response.post;
|
||||
this.draft = this.postToInput(response.post);
|
||||
this.tagsText = this.draft.tags.join(', ');
|
||||
this.editorMessage = '';
|
||||
this.currentBuildJob = null;
|
||||
this.showBuildLog = false;
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
this.view = 'editor';
|
||||
}
|
||||
|
||||
editPost(id: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
void this.selectPost(id);
|
||||
}
|
||||
|
||||
async deletePost(post: Post, event: Event) {
|
||||
event.stopPropagation();
|
||||
if (!window.confirm(`确定删除《${post.title || '未命名'}》吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.deletePost(post.id));
|
||||
if (this.currentPost?.id === post.id) {
|
||||
this.currentPost = null;
|
||||
this.draft = this.emptyDraft();
|
||||
this.tagsText = '';
|
||||
this.view = 'list';
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
await this.loadPosts();
|
||||
if (response.buildJob) {
|
||||
this.currentBuildJob = response.buildJob;
|
||||
this.editorMessage = '已删除,正在重新构建站点';
|
||||
this.watchBuildJob(response.buildJob);
|
||||
}
|
||||
} catch (error) {
|
||||
this.editorMessage = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
newPost() {
|
||||
if (!this.confirmDiscard()) {
|
||||
return;
|
||||
}
|
||||
this.currentPost = null;
|
||||
this.draft = this.emptyDraft();
|
||||
this.tagsText = '';
|
||||
this.editorMessage = '';
|
||||
this.currentBuildJob = null;
|
||||
this.showBuildLog = false;
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
this.view = 'editor';
|
||||
}
|
||||
|
||||
async backToList() {
|
||||
if (!this.confirmDiscard()) {
|
||||
return;
|
||||
}
|
||||
this.view = 'list';
|
||||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async changeStatusFilter() {
|
||||
this.page = 1;
|
||||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async previousPage() {
|
||||
if (this.page <= 1) {
|
||||
return;
|
||||
}
|
||||
this.page -= 1;
|
||||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async nextPage() {
|
||||
if (this.page >= this.totalPages) {
|
||||
return;
|
||||
}
|
||||
this.page += 1;
|
||||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async goToPage(page: number) {
|
||||
if (page < 1 || page > this.totalPages || page === this.page) {
|
||||
return;
|
||||
}
|
||||
this.page = page;
|
||||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async savePost(silent = false) {
|
||||
this.saving = true;
|
||||
if (!silent) {
|
||||
this.editorMessage = '';
|
||||
}
|
||||
if (silent) {
|
||||
this.autosaveStatus = '自动保存中';
|
||||
}
|
||||
try {
|
||||
const input = this.normalizedDraft();
|
||||
const response = this.currentPost
|
||||
? await firstValueFrom(this.api.updatePost(this.currentPost.id, input))
|
||||
: await firstValueFrom(this.api.createPost(input));
|
||||
this.currentPost = response.post;
|
||||
this.draft = this.postToInput(response.post);
|
||||
this.tagsText = this.draft.tags.join(', ');
|
||||
await this.loadPosts();
|
||||
if (!silent) {
|
||||
this.editorMessage = '已保存';
|
||||
this.showFeedback('保存成功', '文章内容已经保存。');
|
||||
this.lastAutosavedAt = null;
|
||||
}
|
||||
if (silent) {
|
||||
this.lastAutosavedAt = new Date();
|
||||
}
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (silent) {
|
||||
this.autosaveStatus = `自动保存失败:${errorMessage(error)}`;
|
||||
} else {
|
||||
const message = errorMessage(error);
|
||||
this.editorMessage = message;
|
||||
this.showFeedback('保存失败', message, 'error');
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async buildPost() {
|
||||
const saved = await this.savePost();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
if (!this.currentPost) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.editorMessage = '';
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.buildPost(this.currentPost.id));
|
||||
this.editorMessage = '已开始构建';
|
||||
this.showFeedback('构建已提交', '正在按照当前状态生成静态站点。', 'info');
|
||||
this.currentBuildJob = response.buildJob;
|
||||
this.showBuildLog = false;
|
||||
this.rememberSavedState();
|
||||
this.updateAutosaveStatus();
|
||||
this.watchBuildJob(response.buildJob);
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
this.editorMessage = message;
|
||||
this.showFeedback('构建失败', message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
watchBuildJob(job: BuildJob, notify = true) {
|
||||
interval(1400)
|
||||
.pipe(
|
||||
switchMap(() => this.api.getBuildJob(job.id)),
|
||||
takeWhile((response) => {
|
||||
const status = response.buildJob.status;
|
||||
this.currentBuildJob = response.buildJob;
|
||||
this.editorMessage =
|
||||
status === 'failed' && response.buildJob.error
|
||||
? `构建失败:${response.buildJob.error}`
|
||||
: `构建状态:${buildStatusText(status)}`;
|
||||
if (!notify) {
|
||||
return !['success', 'failed', 'cancelled'].includes(status);
|
||||
}
|
||||
if (status === 'success') {
|
||||
this.showFeedback('构建完成', '静态页面已经更新。');
|
||||
} else if (status === 'failed') {
|
||||
this.showFeedback('构建失败', response.buildJob.error || '请查看构建日志。', 'error');
|
||||
} else if (status === 'cancelled') {
|
||||
this.showFeedback('构建已取消', '本次构建没有完成。', 'error');
|
||||
}
|
||||
return !['success', 'failed', 'cancelled'].includes(status);
|
||||
}, true),
|
||||
catchError((error) => {
|
||||
const message = errorMessage(error);
|
||||
this.editorMessage = message;
|
||||
this.showFeedback('构建状态获取失败', message, 'error');
|
||||
return of(null);
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
onTitleInput() {
|
||||
if (!this.currentPost && !this.draft.slug.trim()) {
|
||||
this.draft.slug = slugify(this.draft.title);
|
||||
this.draft.slugSource = 'title';
|
||||
this.draft.slugLocked = false;
|
||||
}
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
|
||||
onSlugInput() {
|
||||
this.draft.slugSource = 'manual';
|
||||
this.draft.slugLocked = true;
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
|
||||
hasUnsavedChanges() {
|
||||
return this.editorSnapshot() !== this.savedSnapshot;
|
||||
}
|
||||
|
||||
@HostListener('window:beforeunload', ['$event'])
|
||||
beforeUnload(event: BeforeUnloadEvent) {
|
||||
if (this.hasUnsavedChanges()) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
if (this.user && !this.saving) {
|
||||
void this.savePost();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDraftInput() {
|
||||
this.updateAutosaveStatus();
|
||||
}
|
||||
|
||||
statusText(status: PostStatus) {
|
||||
return statusText(status);
|
||||
}
|
||||
|
||||
formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '无时间';
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
tagText(post: Post) {
|
||||
return post.tags?.length ? post.tags.join(' / ') : '无标签';
|
||||
}
|
||||
|
||||
buildStatusText(status: string) {
|
||||
return buildStatusText(status);
|
||||
}
|
||||
|
||||
toggleBuildLog() {
|
||||
this.showBuildLog = !this.showBuildLog;
|
||||
}
|
||||
|
||||
setEditorMode(mode: 'edit' | 'preview' | 'split') {
|
||||
this.editorMode = mode;
|
||||
}
|
||||
|
||||
closeFeedback() {
|
||||
this.feedback = null;
|
||||
if (this.feedbackTimer) {
|
||||
clearTimeout(this.feedbackTimer);
|
||||
this.feedbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
get previewHtml() {
|
||||
return renderMarkdown(this.draft.bodyMarkdown);
|
||||
}
|
||||
|
||||
async uploadAsset(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadingAsset = true;
|
||||
this.editorMessage = '正在上传图片';
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.uploadAsset(file));
|
||||
this.insertMarkdown(``);
|
||||
this.editorMessage = '图片已插入';
|
||||
this.updateAutosaveStatus();
|
||||
} catch (error) {
|
||||
this.editorMessage = errorMessage(error);
|
||||
} finally {
|
||||
this.uploadingAsset = false;
|
||||
}
|
||||
}
|
||||
|
||||
async generateSlug() {
|
||||
const title = this.draft.title.trim();
|
||||
if (!title) {
|
||||
this.showFeedback('无法生成 Slug', '请先填写标题。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.generatingSlug = true;
|
||||
this.editorMessage = '正在生成 Slug';
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.api.generateSlug(title, this.draft.summary.trim(), this.currentPost?.id)
|
||||
);
|
||||
this.draft.slug = response.slug;
|
||||
this.draft.slugSource = 'ai';
|
||||
this.draft.slugLocked = false;
|
||||
this.editorMessage = 'Slug 已生成';
|
||||
this.showFeedback('Slug 已生成', response.slug);
|
||||
this.updateAutosaveStatus();
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
this.editorMessage = message;
|
||||
this.showFeedback('Slug 生成失败', message, 'error');
|
||||
} finally {
|
||||
this.generatingSlug = false;
|
||||
}
|
||||
}
|
||||
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.totalPosts / this.pageSize));
|
||||
}
|
||||
|
||||
get pageNumbers() {
|
||||
return Array.from({ length: this.totalPages }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
private async autosave() {
|
||||
if (!this.canAutosave()) {
|
||||
return;
|
||||
}
|
||||
await this.savePost(true);
|
||||
}
|
||||
|
||||
private canAutosave() {
|
||||
const input = this.normalizedDraft();
|
||||
return Boolean(
|
||||
this.user &&
|
||||
!this.saving &&
|
||||
this.hasUnsavedChanges() &&
|
||||
input.title &&
|
||||
input.slug
|
||||
);
|
||||
}
|
||||
|
||||
private normalizedDraft(): PostInput {
|
||||
return {
|
||||
...this.draft,
|
||||
title: this.draft.title.trim(),
|
||||
slug: this.draft.slug.trim(),
|
||||
summary: this.draft.summary.trim(),
|
||||
cover: this.draft.cover.trim(),
|
||||
tags: parseTags(this.tagsText),
|
||||
createdAt: datetimeLocalToIso(this.draft.createdAt)
|
||||
};
|
||||
}
|
||||
|
||||
private postToInput(post: Post): PostInput {
|
||||
return {
|
||||
slug: post.slug,
|
||||
title: post.title,
|
||||
summary: post.summary,
|
||||
bodyMarkdown: post.bodyMarkdown,
|
||||
status: post.status === 'deleted' ? 'draft' : post.status,
|
||||
tags: [...(post.tags ?? [])],
|
||||
cover: post.cover,
|
||||
slugSource: post.slugSource || 'manual',
|
||||
slugLocked: post.slugLocked,
|
||||
createdAt: toDateTimeLocal(post.createdAt)
|
||||
};
|
||||
}
|
||||
|
||||
private emptyDraft(): PostInput {
|
||||
return {
|
||||
slug: '',
|
||||
title: '',
|
||||
summary: '',
|
||||
bodyMarkdown: '',
|
||||
status: 'draft',
|
||||
tags: [],
|
||||
cover: '',
|
||||
slugSource: 'manual',
|
||||
slugLocked: true,
|
||||
createdAt: toDateTimeLocal(new Date().toISOString())
|
||||
};
|
||||
}
|
||||
|
||||
private confirmDiscard() {
|
||||
if (!this.hasUnsavedChanges()) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm('当前文章有未保存的修改,确定要离开吗?');
|
||||
}
|
||||
|
||||
private rememberSavedState() {
|
||||
this.savedSnapshot = this.editorSnapshot();
|
||||
}
|
||||
|
||||
private updateAutosaveStatus() {
|
||||
if (this.saving) {
|
||||
return;
|
||||
}
|
||||
if (this.hasUnsavedChanges()) {
|
||||
this.autosaveStatus = '有未保存修改';
|
||||
return;
|
||||
}
|
||||
if (this.lastAutosavedAt) {
|
||||
this.autosaveStatus = `已自动保存 ${this.formatDate(this.lastAutosavedAt.toISOString())}`;
|
||||
return;
|
||||
}
|
||||
this.autosaveStatus = '已保存';
|
||||
}
|
||||
|
||||
private editorSnapshot() {
|
||||
return JSON.stringify({
|
||||
...this.draft,
|
||||
tags: parseTags(this.tagsText)
|
||||
});
|
||||
}
|
||||
|
||||
private insertMarkdown(markdown: string) {
|
||||
const textarea = this.bodyTextarea?.nativeElement;
|
||||
const current = this.draft.bodyMarkdown ?? '';
|
||||
if (!textarea) {
|
||||
this.draft.bodyMarkdown = current ? `${current}\n\n${markdown}\n` : `${markdown}\n`;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart ?? current.length;
|
||||
const end = textarea.selectionEnd ?? current.length;
|
||||
const prefix = current.slice(0, start);
|
||||
const suffix = current.slice(end);
|
||||
const before = prefix && !prefix.endsWith('\n') ? '\n\n' : '';
|
||||
const after = suffix && !suffix.startsWith('\n') ? '\n\n' : '\n';
|
||||
this.draft.bodyMarkdown = `${prefix}${before}${markdown}${after}${suffix}`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
const cursor = start + before.length + markdown.length + after.length;
|
||||
textarea.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
private showFeedback(title: string, message: string, tone: FeedbackTone = 'success') {
|
||||
this.feedback = { title, message, tone };
|
||||
if (this.feedbackTimer) {
|
||||
clearTimeout(this.feedbackTimer);
|
||||
}
|
||||
this.feedbackTimer = setTimeout(() => {
|
||||
this.feedback = null;
|
||||
this.feedbackTimer = null;
|
||||
}, 3200);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTags(tags: string[]) {
|
||||
const seen = new Set<string>();
|
||||
return tags
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => {
|
||||
const key = tag.toLowerCase();
|
||||
if (!tag || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function parseTags(value: string) {
|
||||
return normalizeTags(value.split(/[,,]/));
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function altText(filename: string) {
|
||||
return filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'image';
|
||||
}
|
||||
|
||||
function toDateTimeLocal(value?: string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
return localDate.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function datetimeLocalToIso(value?: string | null) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string) {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const html: string[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let list: string[] = [];
|
||||
let code: string[] = [];
|
||||
let inCode = false;
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraph.length === 0) {
|
||||
return;
|
||||
}
|
||||
html.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
const flushList = () => {
|
||||
if (list.length === 0) {
|
||||
return;
|
||||
}
|
||||
html.push(`<ul>${list.map((item) => `<li>${inlineMarkdown(item)}</li>`).join('')}</ul>`);
|
||||
list = [];
|
||||
};
|
||||
|
||||
const flushCode = () => {
|
||||
html.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
||||
code = [];
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('```')) {
|
||||
if (inCode) {
|
||||
flushCode();
|
||||
inCode = false;
|
||||
} else {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
inCode = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
code.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
const level = heading[1].length;
|
||||
html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*_]{3,}$/.test(trimmed)) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
html.push('<hr>');
|
||||
continue;
|
||||
}
|
||||
|
||||
const listItem = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||
if (listItem) {
|
||||
flushParagraph();
|
||||
list.push(listItem[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
flushCode();
|
||||
}
|
||||
flushParagraph();
|
||||
flushList();
|
||||
|
||||
return html.join('\n');
|
||||
}
|
||||
|
||||
function inlineMarkdown(value: string) {
|
||||
return escapeHtml(value)
|
||||
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<img src="$2" alt="$1">')
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function statusText(status: PostStatus) {
|
||||
return (
|
||||
{
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
archived: '归档',
|
||||
deleted: '已删除'
|
||||
} satisfies Record<PostStatus, string>
|
||||
)[status];
|
||||
}
|
||||
|
||||
function buildStatusText(status: string) {
|
||||
return (
|
||||
{
|
||||
queued: '等待中',
|
||||
running: '构建中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
cancelled: '已取消'
|
||||
} as Record<string, string>
|
||||
)[status] ?? status;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (typeof error === 'object' && error && 'error' in error) {
|
||||
const body = (error as { error?: { error?: string } }).error;
|
||||
if (body?.error) {
|
||||
return body.error;
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return '请求失败';
|
||||
}
|
||||
97
frontend/admin/src/app/models.ts
Normal file
97
frontend/admin/src/app/models.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
export type PostStatus = 'draft' | 'published' | 'archived' | 'deleted';
|
||||
export type BuildJobStatus = 'queued' | 'running' | 'success' | 'failed' | 'cancelled';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt?: string | null;
|
||||
};
|
||||
|
||||
export type Post = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
bodyMarkdown: string;
|
||||
status: PostStatus;
|
||||
tags: string[];
|
||||
cover: string;
|
||||
version: number;
|
||||
slugSource: string;
|
||||
slugLocked: boolean;
|
||||
publishedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
};
|
||||
|
||||
export type PostInput = {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
bodyMarkdown: string;
|
||||
status: PostStatus;
|
||||
tags: string[];
|
||||
cover: string;
|
||||
slugSource: string;
|
||||
slugLocked: boolean;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
export type BuildJob = {
|
||||
id: string;
|
||||
trigger: string;
|
||||
status: BuildJobStatus;
|
||||
postId?: string | null;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
log: string;
|
||||
error: string;
|
||||
createdAt: string;
|
||||
createdBy?: string | null;
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
id: string;
|
||||
path: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
createdAt: string;
|
||||
createdBy?: string | null;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
user: User;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type PostResponse = {
|
||||
post: Post;
|
||||
buildJob?: BuildJob | null;
|
||||
};
|
||||
|
||||
export type PostsResponse = {
|
||||
posts: Post[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type BuildJobResponse = {
|
||||
buildJob: BuildJob;
|
||||
};
|
||||
|
||||
export type AssetResponse = {
|
||||
asset: Asset;
|
||||
};
|
||||
|
||||
export type SlugResponse = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type DeletePostResponse = {
|
||||
ok: boolean;
|
||||
buildJob?: BuildJob | null;
|
||||
};
|
||||
12
frontend/admin/src/index.html
Normal file
12
frontend/admin/src/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Osaet Admin</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<osaet-root></osaet-root>
|
||||
</body>
|
||||
</html>
|
||||
9
frontend/admin/src/main.ts
Normal file
9
frontend/admin/src/main.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [provideHttpClient(withFetch()), provideAnimations()]
|
||||
}).catch((error) => console.error(error));
|
||||
29
frontend/admin/src/styles.css
Normal file
29
frontend/admin/src/styles.css
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
:root {
|
||||
color: #24262b;
|
||||
background: #f7f6f2;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC",
|
||||
"Source Han Sans SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f7f6f2;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
}
|
||||
9
frontend/admin/tsconfig.app.json
Normal file
9
frontend/admin/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
28
frontend/admin/tsconfig.json
Normal file
28
frontend/admin/tsconfig.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "dom"]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
1
frontend/site/public/assets/.gitkeep
Normal file
1
frontend/site/public/assets/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue