feat: add fullstack deployment and oracle app
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
web/.vite/
|
||||||
|
|
||||||
|
server/.cache/
|
||||||
|
server/bin/
|
||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: ./server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: xly-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8181:8181"
|
||||||
|
volumes:
|
||||||
|
- ./server/.env:/app/.env:ro
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./web
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: xly-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
ports:
|
||||||
|
- "5174:80"
|
||||||
|
volumes:
|
||||||
|
- ./web/.env:/usr/share/nginx/html/.env:ro
|
||||||
4
server/.dockerignore
Normal file
4
server/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
.git
|
||||||
|
bin
|
||||||
|
*.log
|
||||||
8
server/.env.example
Normal file
8
server/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
SERVER_PORT=8181
|
||||||
|
AI_PROVIDER_NAME=OpenAI Compatible
|
||||||
|
AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode
|
||||||
|
AI_CHAT_PATH=/v1/chat/completions
|
||||||
|
AI_MODEL=qwen3.5-397b-a17b
|
||||||
|
AI_API_KEY=sk-028de7b23ecb47fc8c4bd069679b8420
|
||||||
|
AI_TIMEOUT_SECONDS=60
|
||||||
|
WEB_ALLOWED_ORIGINS=http://127.0.0.1:5174,http://localhost:5174
|
||||||
1
server/.gitignore
vendored
Normal file
1
server/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
23
server/Dockerfile
Normal file
23
server/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM golang:1.26.2-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/xly-server ./cmd/xly-server
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN adduser -D -H -u 10001 appuser
|
||||||
|
|
||||||
|
COPY --from=builder /out/xly-server /app/xly-server
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8181
|
||||||
|
|
||||||
|
CMD ["./xly-server"]
|
||||||
44
server/cmd/xly-server/main.go
Normal file
44
server/cmd/xly-server/main.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
|
"xly/server/internal/config"
|
||||||
|
"xly/server/internal/handler"
|
||||||
|
"xly/server/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := godotenv.Load(".env"); err != nil {
|
||||||
|
log.Printf("skip .env load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(gin.Logger(), gin.Recovery())
|
||||||
|
if err := router.SetTrustedProxies(nil); err != nil {
|
||||||
|
log.Fatalf("set trusted proxies: %v", err)
|
||||||
|
}
|
||||||
|
router.Use(cors.New(cors.Config{
|
||||||
|
AllowOrigins: cfg.AllowedOrigins,
|
||||||
|
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions},
|
||||||
|
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
|
||||||
|
MaxAge: 12 * time.Hour,
|
||||||
|
}))
|
||||||
|
|
||||||
|
aiService := service.NewAIService(cfg)
|
||||||
|
httpHandler := handler.NewHTTPHandler(aiService)
|
||||||
|
httpHandler.Register(router)
|
||||||
|
|
||||||
|
address := ":" + cfg.Port
|
||||||
|
log.Printf("xly server listening on %s", address)
|
||||||
|
if err := router.Run(address); err != nil {
|
||||||
|
log.Fatalf("start gin server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
server/go.mod
Normal file
41
server/go.mod
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
module xly/server
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/cors v1.7.7
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/joho/godotenv v1.5.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/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/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/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/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.23.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
93
server/go.sum
Normal file
93
server/go.sum
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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/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/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
||||||
|
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
||||||
|
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
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/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/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/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.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.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||||
|
golang.org/x/arch v0.23.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/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/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
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/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=
|
||||||
91
server/internal/config/config.go
Normal file
91
server/internal/config/config.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 定义服务运行配置。
|
||||||
|
// AI 调用统一走 OpenAI 兼容协议,上游地址和鉴权由环境变量提供。
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
ProviderName string
|
||||||
|
AIBaseURL string
|
||||||
|
AIChatPath string
|
||||||
|
AIAPIKey string
|
||||||
|
AIModel string
|
||||||
|
AITimeout time.Duration
|
||||||
|
AllowedOrigins []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load 从环境变量装载运行配置。
|
||||||
|
// 未提供的字段使用保守默认值,确保本地开发可以直接启动服务。
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
Port: getenv("SERVER_PORT", "8080"),
|
||||||
|
ProviderName: getenv("AI_PROVIDER_NAME", "OpenAI Compatible"),
|
||||||
|
AIBaseURL: strings.TrimRight(getenv("AI_BASE_URL", "https://api.openai.com"), "/"),
|
||||||
|
AIChatPath: normalizePath(getenv("AI_CHAT_PATH", "/v1/chat/completions")),
|
||||||
|
AIAPIKey: strings.TrimSpace(os.Getenv("AI_API_KEY")),
|
||||||
|
AIModel: strings.TrimSpace(os.Getenv("AI_MODEL")),
|
||||||
|
AITimeout: time.Duration(getenvInt("AI_TIMEOUT_SECONDS", 60)) * time.Second,
|
||||||
|
AllowedOrigins: splitOrigins(getenv("WEB_ALLOWED_ORIGINS", "http://127.0.0.1:5173,http://localhost:5173")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIEnabled 判断 AI 服务是否已具备调用条件。
|
||||||
|
func (c Config) AIEnabled() bool {
|
||||||
|
return c.AIAPIKey != "" && c.AIModel != "" && c.AIBaseURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key string, fallback string) string {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvInt(key string, fallback int) int {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePath(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return "/v1/chat/completions"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "/") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return "/" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitOrigins(raw string) []string {
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
origins := make([]string, 0, len(parts))
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
origin := strings.TrimSpace(part)
|
||||||
|
if origin != "" {
|
||||||
|
origins = append(origins, origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(origins) == 0 {
|
||||||
|
return []string{"http://127.0.0.1:5173", "http://localhost:5173"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return origins
|
||||||
|
}
|
||||||
90
server/internal/handler/http.go
Normal file
90
server/internal/handler/http.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"xly/server/internal/model"
|
||||||
|
"xly/server/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPHandler 负责注册 HTTP 路由。
|
||||||
|
// 控制层只做参数校验和响应包装,不承担上游调用逻辑。
|
||||||
|
type HTTPHandler struct {
|
||||||
|
ai *service.AIService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPHandler 创建 HTTP 控制器。
|
||||||
|
func NewHTTPHandler(ai *service.AIService) *HTTPHandler {
|
||||||
|
return &HTTPHandler{ai: ai}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 将接口注册到 Gin 路由树。
|
||||||
|
func (h *HTTPHandler) Register(router gin.IRouter) {
|
||||||
|
router.GET("/healthz", h.healthz)
|
||||||
|
router.GET("/api/v1/ai/status", h.aiStatus)
|
||||||
|
router.POST("/api/v1/ai/interpret", h.aiInterpret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) healthz(ctx *gin.Context) {
|
||||||
|
ctx.JSON(http.StatusOK, model.APIResponse[map[string]string]{
|
||||||
|
Data: &map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
},
|
||||||
|
Message: "service is healthy",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) aiStatus(ctx *gin.Context) {
|
||||||
|
status := h.ai.Status()
|
||||||
|
ctx.JSON(http.StatusOK, model.APIResponse[model.AIStatusResponse]{
|
||||||
|
Data: &status,
|
||||||
|
Message: "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) aiInterpret(ctx *gin.Context) {
|
||||||
|
var request model.InterpretRequest
|
||||||
|
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, model.APIResponse[model.InterpretResponse]{
|
||||||
|
Message: "请求体无效,请检查解卦上下文是否完整。",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Question = strings.TrimSpace(request.Question)
|
||||||
|
if request.Question == "" {
|
||||||
|
ctx.JSON(http.StatusBadRequest, model.APIResponse[model.InterpretResponse]{
|
||||||
|
Message: "所问之事不能为空。",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := h.ai.Interpret(ctx.Request.Context(), request)
|
||||||
|
if err != nil {
|
||||||
|
statusCode := http.StatusBadGateway
|
||||||
|
message := "AI 解卦失败。"
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrAIUnavailable):
|
||||||
|
statusCode = http.StatusServiceUnavailable
|
||||||
|
message = "后端 AI 服务尚未完成配置。"
|
||||||
|
default:
|
||||||
|
message = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(statusCode, model.APIResponse[model.InterpretResponse]{
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := model.InterpretResponse{Content: content}
|
||||||
|
ctx.JSON(http.StatusOK, model.APIResponse[model.InterpretResponse]{
|
||||||
|
Data: &response,
|
||||||
|
Message: "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
72
server/internal/model/api.go
Normal file
72
server/internal/model/api.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// AIStatusResponse 定义前端可见的 AI 服务状态。
|
||||||
|
// 该对象只暴露公开信息,不包含密钥和上游鉴权细节。
|
||||||
|
type AIStatusResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ProviderName string `json:"providerName"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterpretSlot 定义单个课位的占断上下文。
|
||||||
|
// 该对象同时包含取值来源和落宫结果,供后端组装用户消息。
|
||||||
|
type InterpretSlot struct {
|
||||||
|
Label string `json:"label" binding:"required"`
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
Value int `json:"value" binding:"required"`
|
||||||
|
Source string `json:"source" binding:"required"`
|
||||||
|
SourceNote string `json:"sourceNote" binding:"required"`
|
||||||
|
GanZhi string `json:"ganZhi"`
|
||||||
|
Palace string `json:"palace" binding:"required"`
|
||||||
|
Element string `json:"element" binding:"required"`
|
||||||
|
Theme string `json:"theme" binding:"required"`
|
||||||
|
Detail string `json:"detail" binding:"required"`
|
||||||
|
SlotExplanation string `json:"slotExplanation" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterpretRelation 定义四宫之间的生克链路。
|
||||||
|
type InterpretRelation struct {
|
||||||
|
Kind string `json:"kind" binding:"required"`
|
||||||
|
Summary string `json:"summary" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterpretSummary 定义前端本地规则得出的摘要结果。
|
||||||
|
type InterpretSummary struct {
|
||||||
|
Verdict string `json:"verdict" binding:"required"`
|
||||||
|
Score int `json:"score" binding:"required"`
|
||||||
|
Summary string `json:"summary" binding:"required"`
|
||||||
|
DomainLabel string `json:"domainLabel" binding:"required"`
|
||||||
|
ChainSummary string `json:"chainSummary"`
|
||||||
|
ActionAdvice []string `json:"actionAdvice" binding:"required"`
|
||||||
|
RiskAdvice []string `json:"riskAdvice" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterpretRequest 定义 AI 解卦请求。
|
||||||
|
// 前端只传结构化盘面数据,具体提示词由后端统一维护。
|
||||||
|
type InterpretRequest struct {
|
||||||
|
Mode string `json:"mode" binding:"required"`
|
||||||
|
ModeLabel string `json:"modeLabel" binding:"required"`
|
||||||
|
Question string `json:"question" binding:"required"`
|
||||||
|
SolarLabel string `json:"solarLabel" binding:"required"`
|
||||||
|
LunarLabel string `json:"lunarLabel" binding:"required"`
|
||||||
|
MethodNote string `json:"methodNote" binding:"required"`
|
||||||
|
FinalPalace string `json:"finalPalace" binding:"required"`
|
||||||
|
FinalElement string `json:"finalElement" binding:"required"`
|
||||||
|
Slots []InterpretSlot `json:"slots" binding:"required,min=4"`
|
||||||
|
Relations []InterpretRelation `json:"relations"`
|
||||||
|
LocalInterpretation InterpretSummary `json:"localInterpretation" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InterpretResponse 定义 AI 解卦响应。
|
||||||
|
type InterpretResponse struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIResponse 统一包装 HTTP 返回体。
|
||||||
|
// Message 用于说明当前结果或错误原因。
|
||||||
|
type APIResponse[T any] struct {
|
||||||
|
Data *T `json:"data,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
296
server/internal/service/ai.go
Normal file
296
server/internal/service/ai.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"xly/server/internal/config"
|
||||||
|
"xly/server/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrAIUnavailable = errors.New("ai service is not configured")
|
||||||
|
|
||||||
|
const divinationSystemPrompt = `你是“四课小六壬”解卦助手,负责把盘面结果翻译成现实语境下可执行的中文建议。
|
||||||
|
|
||||||
|
你的工作边界:
|
||||||
|
1. 只基于用户提供的盘面数据解读,不臆造不存在的课位、关系或额外神煞。
|
||||||
|
2. 结果必须写成趋势判断、阻力判断和行动建议,不得写成绝对预言。
|
||||||
|
3. 语言要直白、稳重、可落实,不故作神秘,不堆砌玄学术语。
|
||||||
|
4. 优先解释“为什么这样判断”,尤其要结合四宫次第、终宫落点与五行生克。
|
||||||
|
|
||||||
|
内置断法:
|
||||||
|
1. 四课顺序固定为年宫 → 月宫 → 日宫 → 时宫。
|
||||||
|
2. 年宫主大环境、外部周期、权力方态度。
|
||||||
|
3. 月宫主当下阶段的资源、氛围、预算、团队状态。
|
||||||
|
4. 日宫主执行面、沟通、临场表现、眼前动作。
|
||||||
|
5. 时宫主结果落点,成败判断以时宫为主。
|
||||||
|
6. 要结合四宫之间的生、克、比和链路判断助力与阻力是否顺势串联。
|
||||||
|
7. 前端给出的“本地规则总断”只能作为参考线索,不能机械复述,要重新组织成完整分析。
|
||||||
|
|
||||||
|
输出规则:
|
||||||
|
1. 必须使用 Markdown。
|
||||||
|
2. 必须严格按下面结构输出,不要新增无关标题,不要输出代码块:
|
||||||
|
### 总断
|
||||||
|
先用 1 句总结走势,再用 1 段解释核心判断。
|
||||||
|
|
||||||
|
### 四宫详解与五行生克分析
|
||||||
|
#### 年宫
|
||||||
|
#### 月宫
|
||||||
|
#### 日宫
|
||||||
|
#### 时宫
|
||||||
|
四个小节都要解释:该宫位含义、当前盘面显示的倾向、它对结果的作用。
|
||||||
|
这一大节最后补 1 段,专门总结生克链路是顺还是逆、哪里在助推、哪里在掣肘。
|
||||||
|
|
||||||
|
### 趋吉次第
|
||||||
|
输出 3 到 4 条有先后顺序的编号列表。每条都必须是可执行动作。
|
||||||
|
|
||||||
|
### 避忌要点
|
||||||
|
输出 2 到 3 条无序列表。每条都写清具体风险。
|
||||||
|
|
||||||
|
### 结语
|
||||||
|
最后单独写一句:仅供民俗文化参考,不替代现实决策。
|
||||||
|
|
||||||
|
格式细则:
|
||||||
|
1. 标题只用 ### 和 ####。
|
||||||
|
2. 列表只用“1.”或“-”。
|
||||||
|
3. 不要输出表格。
|
||||||
|
4. 不要用“根据你提供的信息我认为”这种空话开头。
|
||||||
|
5. 不要在标题前后添加多余的星号。`
|
||||||
|
|
||||||
|
// AIService 负责代理上游 AI 服务。
|
||||||
|
// 该服务只暴露统一的解卦入口,不向前端泄露上游鉴权细节。
|
||||||
|
type AIService struct {
|
||||||
|
config config.Config
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type upstreamChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
Messages []upstreamChatMessage `json:"messages"`
|
||||||
|
EnableThinking bool `json:"enable_thinking"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type upstreamChatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type upstreamChatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content any `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAIService 创建 AI 代理服务。
|
||||||
|
func NewAIService(cfg config.Config) *AIService {
|
||||||
|
return &AIService{
|
||||||
|
config: cfg,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: cfg.AITimeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status 返回前端可见的 AI 服务状态。
|
||||||
|
func (s *AIService) Status() model.AIStatusResponse {
|
||||||
|
if !s.config.AIEnabled() {
|
||||||
|
return model.AIStatusResponse{
|
||||||
|
Enabled: false,
|
||||||
|
ProviderName: s.config.ProviderName,
|
||||||
|
Model: emptyAs(s.config.AIModel, "未配置"),
|
||||||
|
Mode: "server-proxy",
|
||||||
|
Message: "后端已启动,但 AI_BASE_URL / AI_MODEL / AI_API_KEY 尚未完整配置。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.AIStatusResponse{
|
||||||
|
Enabled: true,
|
||||||
|
ProviderName: s.config.ProviderName,
|
||||||
|
Model: s.config.AIModel,
|
||||||
|
Mode: "server-proxy",
|
||||||
|
Message: "后端将代表前端调用上游 AI 服务。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpret 调用上游 AI 服务生成解卦文本。
|
||||||
|
func (s *AIService) Interpret(ctx context.Context, input model.InterpretRequest) (string, error) {
|
||||||
|
if !s.config.AIEnabled() {
|
||||||
|
return "", ErrAIUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(upstreamChatRequest{
|
||||||
|
Model: s.config.AIModel,
|
||||||
|
Temperature: 0.8,
|
||||||
|
EnableThinking: false,
|
||||||
|
Messages: []upstreamChatMessage{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: divinationSystemPrompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: buildUserPrompt(input),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal upstream request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
s.config.AIBaseURL+s.config.AIChatPath,
|
||||||
|
bytes.NewReader(payload),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("build upstream request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+s.config.AIAPIKey)
|
||||||
|
|
||||||
|
response, err := s.httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request upstream ai: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read upstream response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded upstreamChatResponse
|
||||||
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||||
|
return "", fmt.Errorf("decode upstream response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.StatusCode >= http.StatusBadRequest {
|
||||||
|
message := "上游 AI 服务返回错误。"
|
||||||
|
if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" {
|
||||||
|
message = decoded.Error.Message
|
||||||
|
}
|
||||||
|
return "", errors.New(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(decoded.Choices) == 0 {
|
||||||
|
return "", errors.New("上游 AI 服务未返回可用结果")
|
||||||
|
}
|
||||||
|
|
||||||
|
content := extractContent(decoded.Choices[0].Message.Content)
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return "", errors.New("上游 AI 服务返回成功,但内容为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUserPrompt(input model.InterpretRequest) string {
|
||||||
|
slotLines := make([]string, 0, len(input.Slots))
|
||||||
|
for _, slot := range input.Slots {
|
||||||
|
slotLines = append(slotLines, fmt.Sprintf(
|
||||||
|
"%s:取值=%s(%d),来源=%s,来源说明=%s,落宫=%s,五行=%s,宫义=%s%s,职责=%s,干支参考=%s",
|
||||||
|
slot.Label,
|
||||||
|
slot.Token,
|
||||||
|
slot.Value,
|
||||||
|
slot.Source,
|
||||||
|
slot.SourceNote,
|
||||||
|
slot.Palace,
|
||||||
|
slot.Element,
|
||||||
|
slot.Theme,
|
||||||
|
slot.Detail,
|
||||||
|
slot.SlotExplanation,
|
||||||
|
emptyAs(slot.GanZhi, "-"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
relationLines := make([]string, 0, len(input.Relations))
|
||||||
|
for _, relation := range input.Relations {
|
||||||
|
relationLines = append(relationLines, fmt.Sprintf("- %s:%s", relation.Kind, relation.Summary))
|
||||||
|
}
|
||||||
|
if len(relationLines) == 0 {
|
||||||
|
relationLines = append(relationLines, "- 无可用链路")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(`问题:%s
|
||||||
|
起课方式:%s(%s)
|
||||||
|
公历时间:%s
|
||||||
|
农历时间:%s
|
||||||
|
起课说明:%s
|
||||||
|
终宫:%s · 五行%s
|
||||||
|
|
||||||
|
四宫盘面:
|
||||||
|
%s
|
||||||
|
|
||||||
|
生克链路:
|
||||||
|
%s
|
||||||
|
|
||||||
|
本地规则总断参考:
|
||||||
|
- 总评:%s
|
||||||
|
- 分数:%d
|
||||||
|
- 摘要:%s
|
||||||
|
- 事项类型:%s
|
||||||
|
- 链路总结:%s
|
||||||
|
- 趋吉建议:%s
|
||||||
|
- 风险提示:%s`,
|
||||||
|
input.Question,
|
||||||
|
input.ModeLabel,
|
||||||
|
input.Mode,
|
||||||
|
input.SolarLabel,
|
||||||
|
input.LunarLabel,
|
||||||
|
input.MethodNote,
|
||||||
|
input.FinalPalace,
|
||||||
|
input.FinalElement,
|
||||||
|
strings.Join(slotLines, "\n"),
|
||||||
|
strings.Join(relationLines, "\n"),
|
||||||
|
input.LocalInterpretation.Verdict,
|
||||||
|
input.LocalInterpretation.Score,
|
||||||
|
input.LocalInterpretation.Summary,
|
||||||
|
input.LocalInterpretation.DomainLabel,
|
||||||
|
emptyAs(input.LocalInterpretation.ChainSummary, "无"),
|
||||||
|
strings.Join(input.LocalInterpretation.ActionAdvice, ";"),
|
||||||
|
strings.Join(input.LocalInterpretation.RiskAdvice, ";"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractContent(content any) string {
|
||||||
|
switch value := content.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
case []any:
|
||||||
|
parts := make([]string, 0, len(value))
|
||||||
|
for _, item := range value {
|
||||||
|
part, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, _ := part["text"].(string)
|
||||||
|
if strings.TrimSpace(text) != "" {
|
||||||
|
parts = append(parts, strings.TrimSpace(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyAs(value string, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
6
web/.dockerignore
Normal file
6
web/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
*.log
|
||||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
../node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
20
web/Dockerfile
Normal file
20
web/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY docker/40-xly-env.sh /docker-entrypoint.d/40-xly-env.sh
|
||||||
|
COPY public/env.js /usr/share/nginx/html/env.js
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-xly-env.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
73
web/README.md
Normal file
73
web/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
17
web/docker/40-xly-env.sh
Normal file
17
web/docker/40-xly-env.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ENV_FILE="/usr/share/nginx/html/.env"
|
||||||
|
OUTPUT_FILE="/usr/share/nginx/html/env.js"
|
||||||
|
|
||||||
|
api_base_url=""
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
api_base_url="$(grep '^VITE_API_BASE_URL=' "$ENV_FILE" | tail -n 1 | cut -d '=' -f 2- || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >"$OUTPUT_FILE" <<EOF
|
||||||
|
window.__XLY_CONFIG__ = {
|
||||||
|
VITE_API_BASE_URL: "${api_base_url}"
|
||||||
|
};
|
||||||
|
EOF
|
||||||
22
web/eslint.config.js
Normal file
22
web/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
14
web/index.html
Normal file
14
web/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>xly</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="/env.js"></script>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
web/nginx.conf
Normal file
11
web/nginx.conf
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
2749
web/package-lock.json
generated
Normal file
2749
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
web/package.json
Normal file
31
web/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "xly",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "../node_modules/.bin/vite",
|
||||||
|
"build": "../node_modules/.bin/tsc -b && vite build",
|
||||||
|
"lint": "../node_modules/.bin/eslint .",
|
||||||
|
"preview": "../node_modules/.bin/vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lunar-typescript": "^1.8.6",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.2",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
web/public/env.js
Normal file
1
web/public/env.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
window.__XLY_CONFIG__ = window.__XLY_CONFIG__ || {};
|
||||||
1
web/public/favicon.svg
Normal file
1
web/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
web/public/icons.svg
Normal file
24
web/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
1284
web/src/App.css
Normal file
1284
web/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
784
web/src/App.tsx
Normal file
784
web/src/App.tsx
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
import { Solar } from 'lunar-typescript'
|
||||||
|
import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import './App.css'
|
||||||
|
import { requestAiInterpretation } from './lib/ai.ts'
|
||||||
|
import { parseAiOutput, type AiInlineToken } from './lib/ai-render.ts'
|
||||||
|
import {
|
||||||
|
branchOptions,
|
||||||
|
buildDivination,
|
||||||
|
compassNumberOptions,
|
||||||
|
defaultCompassNumbers,
|
||||||
|
getDefaultDatetimeValue,
|
||||||
|
type BranchName,
|
||||||
|
type BranchSlot,
|
||||||
|
type CompassNumbers,
|
||||||
|
type DivinationMode,
|
||||||
|
type DivinationResult,
|
||||||
|
type ManualBranchOverrides,
|
||||||
|
type PalaceResult,
|
||||||
|
} from './lib/divination.ts'
|
||||||
|
import { exportReportImage } from './lib/report.ts'
|
||||||
|
|
||||||
|
const QUICK_QUESTIONS = [
|
||||||
|
'此事当前推进,成败如何?',
|
||||||
|
'所谋之局,先动还是先守?',
|
||||||
|
'眼下沟通,宜直言还是缓进?',
|
||||||
|
'此番机缘,能否落到实处?',
|
||||||
|
]
|
||||||
|
|
||||||
|
const SLOT_LABELS: Record<BranchSlot, string> = {
|
||||||
|
year: '年支',
|
||||||
|
month: '月支',
|
||||||
|
day: '日支',
|
||||||
|
time: '时支',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRANCH_MONTH_HINT: Record<BranchName, string> = {
|
||||||
|
子: '约阳历12月',
|
||||||
|
丑: '约阳历01月',
|
||||||
|
寅: '约阳历02月',
|
||||||
|
卯: '约阳历03月',
|
||||||
|
辰: '约阳历04月',
|
||||||
|
巳: '约阳历05月',
|
||||||
|
午: '约阳历06月',
|
||||||
|
未: '约阳历07月',
|
||||||
|
申: '约阳历08月',
|
||||||
|
酉: '约阳历09月',
|
||||||
|
戌: '约阳历10月',
|
||||||
|
亥: '约阳历11月',
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRANCH_TIME_HINT: Record<BranchName, string> = {
|
||||||
|
子: '23:00-00:59',
|
||||||
|
丑: '01:00-02:59',
|
||||||
|
寅: '03:00-04:59',
|
||||||
|
卯: '05:00-06:59',
|
||||||
|
辰: '07:00-08:59',
|
||||||
|
巳: '09:00-10:59',
|
||||||
|
午: '11:00-12:59',
|
||||||
|
未: '13:00-14:59',
|
||||||
|
申: '15:00-16:59',
|
||||||
|
酉: '17:00-18:59',
|
||||||
|
戌: '19:00-20:59',
|
||||||
|
亥: '21:00-22:59',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRATEGY_LABELS = ['先定主问', '再观时势', '后落动作', '收束结果']
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [question, setQuestion] = useState(QUICK_QUESTIONS[0])
|
||||||
|
const [datetime, setDatetime] = useState(getDefaultDatetimeValue())
|
||||||
|
const [mode, setMode] = useState<DivinationMode>('time')
|
||||||
|
const [manualMode, setManualMode] = useState(false)
|
||||||
|
const [overrides, setOverrides] = useState<ManualBranchOverrides>({})
|
||||||
|
const [compassNumbers, setCompassNumbers] = useState<CompassNumbers>(defaultCompassNumbers)
|
||||||
|
const [committedResult, setCommittedResult] = useState<DivinationResult>(() =>
|
||||||
|
buildDivination({
|
||||||
|
question: QUICK_QUESTIONS[0],
|
||||||
|
datetime: getDefaultDatetimeValue(),
|
||||||
|
mode: 'time',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const [aiInterpretation, setAiInterpretation] = useState('')
|
||||||
|
const [aiError, setAiError] = useState('')
|
||||||
|
const [isAiLoading, setIsAiLoading] = useState(false)
|
||||||
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
|
const [actionHint, setActionHint] = useState('')
|
||||||
|
const [scrollOracleToken, setScrollOracleToken] = useState(0)
|
||||||
|
|
||||||
|
const oracleRef = useRef<HTMLElement | null>(null)
|
||||||
|
const deferredQuestion = useDeferredValue(question)
|
||||||
|
|
||||||
|
const previewResult = useMemo(
|
||||||
|
() =>
|
||||||
|
buildDivination({
|
||||||
|
question: deferredQuestion,
|
||||||
|
datetime,
|
||||||
|
mode,
|
||||||
|
overrides: mode === 'time' && manualMode ? overrides : undefined,
|
||||||
|
compassNumbers,
|
||||||
|
}),
|
||||||
|
[compassNumbers, datetime, deferredQuestion, manualMode, mode, overrides],
|
||||||
|
)
|
||||||
|
|
||||||
|
const branchOptionLabels = useMemo(
|
||||||
|
() =>
|
||||||
|
branchOptions.map((branch) => ({
|
||||||
|
branch,
|
||||||
|
year: `${branch} · ${buildYearExamples(branch, datetime)}`,
|
||||||
|
month: `${branch} · ${BRANCH_MONTH_HINT[branch]}`,
|
||||||
|
day: `${branch} · ${buildDayExamples(branch, datetime)}`,
|
||||||
|
time: `${branch} · ${BRANCH_TIME_HINT[branch]}`,
|
||||||
|
})),
|
||||||
|
[datetime],
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentBranchHints = useMemo(() => {
|
||||||
|
const yearBranch = resolveBranchValue('year', overrides, previewResult)
|
||||||
|
const monthBranch = resolveBranchValue('month', overrides, previewResult)
|
||||||
|
const dayBranch = resolveBranchValue('day', overrides, previewResult)
|
||||||
|
const timeBranch = resolveBranchValue('time', overrides, previewResult)
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: buildCurrentBranchHint('year', yearBranch, datetime),
|
||||||
|
month: buildCurrentBranchHint('month', monthBranch, datetime),
|
||||||
|
day: buildCurrentBranchHint('day', dayBranch, datetime),
|
||||||
|
time: buildCurrentBranchHint('time', timeBranch, datetime),
|
||||||
|
}
|
||||||
|
}, [datetime, overrides, previewResult])
|
||||||
|
|
||||||
|
const finalPalace = committedResult.finalPalace
|
||||||
|
const relationSummary = committedResult.relations.map((item) => item.summary).join(';')
|
||||||
|
const aiBlocks = useMemo(() => parseAiOutput(aiInterpretation), [aiInterpretation])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actionHint) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => setActionHint(''), 1800)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [actionHint])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollOracleToken) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
oracleRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [scrollOracleToken])
|
||||||
|
|
||||||
|
const handleCast = async () => {
|
||||||
|
const nextResult = previewResult
|
||||||
|
setCommittedResult(nextResult)
|
||||||
|
setAiError('')
|
||||||
|
setAiInterpretation('')
|
||||||
|
setScrollOracleToken((value) => value + 1)
|
||||||
|
await runAiInterpretation(nextResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetryAi = async () => {
|
||||||
|
setScrollOracleToken((value) => value + 1)
|
||||||
|
await runAiInterpretation(committedResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const text = [
|
||||||
|
`所问:${committedResult.question}`,
|
||||||
|
`起课:${committedResult.modeLabel}`,
|
||||||
|
`公历:${committedResult.solarLabel}`,
|
||||||
|
`农历:${committedResult.lunarLabel}`,
|
||||||
|
`终宫:${committedResult.finalPalace.palace}`,
|
||||||
|
`总断:${committedResult.localInterpretation.verdict}`,
|
||||||
|
`解断:${committedResult.localInterpretation.summary}`,
|
||||||
|
`趋吉:${committedResult.localInterpretation.actionAdvice.join(';')}`,
|
||||||
|
`避忌:${committedResult.localInterpretation.riskAdvice.join(';')}`,
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setActionHint('已拓印')
|
||||||
|
} catch {
|
||||||
|
setActionHint('拓印失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportReport = async () => {
|
||||||
|
setIsExporting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exportReportImage({
|
||||||
|
result: committedResult,
|
||||||
|
aiInterpretation,
|
||||||
|
})
|
||||||
|
setActionHint('已导出图片报告')
|
||||||
|
} catch {
|
||||||
|
setActionHint('导出失败')
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillRandomCompassNumbers = () => {
|
||||||
|
setCompassNumbers({
|
||||||
|
first: randomCompassNumber(),
|
||||||
|
second: randomCompassNumber(),
|
||||||
|
third: randomCompassNumber(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAiInterpretation(result: DivinationResult) {
|
||||||
|
setIsAiLoading(true)
|
||||||
|
setAiError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await requestAiInterpretation(result)
|
||||||
|
setAiInterpretation(content)
|
||||||
|
} catch (error) {
|
||||||
|
setAiError(error instanceof Error ? error.message : 'AI 解卦失败,请检查后端服务。')
|
||||||
|
} finally {
|
||||||
|
setIsAiLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-shell">
|
||||||
|
<main className="site-main">
|
||||||
|
<section className="altar" id="altar">
|
||||||
|
<div className="section-head">
|
||||||
|
<p className="eyebrow">玄坛问时 · 四课断意</p>
|
||||||
|
<h1 className="altar-title">四课小六壬排盘台</h1>
|
||||||
|
<p className="section-kicker">第一步</p>
|
||||||
|
<h2>立时、定念、落挂牌盘</h2>
|
||||||
|
<p className="section-summary">
|
||||||
|
你可以按时刻起课,也可以用罗盘三数起课。前端只负责排盘,点击“落挂牌盘”后由后端统一调用 AI 解卦。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="altar-grid">
|
||||||
|
<section className="ritual-form">
|
||||||
|
<div className="mode-switcher" role="tablist" aria-label="起课方式">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === 'time' ? 'mode-chip mode-chip-active' : 'mode-chip'}
|
||||||
|
onClick={() => setMode('time')}
|
||||||
|
>
|
||||||
|
时刻起课
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === 'compass' ? 'mode-chip mode-chip-active' : 'mode-chip'}
|
||||||
|
onClick={() => setMode('compass')}
|
||||||
|
>
|
||||||
|
罗盘三数起课
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>所问之事</span>
|
||||||
|
<textarea
|
||||||
|
value={question}
|
||||||
|
onChange={(event) => setQuestion(event.target.value)}
|
||||||
|
placeholder="例如:这次沟通现在去谈,结果会怎样?"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="quick-tags" role="list" aria-label="常用问法">
|
||||||
|
{QUICK_QUESTIONS.map((item) => (
|
||||||
|
<button key={item} type="button" className="tag" onClick={() => setQuestion(item)}>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-row">
|
||||||
|
<label className="field field-main">
|
||||||
|
<span>{mode === 'time' ? '起卦时刻' : '记录时刻'}</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={datetime}
|
||||||
|
onChange={(event) => setDatetime(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-action ghost-action-inline"
|
||||||
|
onClick={() => setDatetime(getDefaultDatetimeValue())}
|
||||||
|
>
|
||||||
|
取此刻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'time' ? (
|
||||||
|
<>
|
||||||
|
<div className="manual-switch">
|
||||||
|
<div>
|
||||||
|
<p>手动调息(流派异同)</p>
|
||||||
|
<span>只在晚子时、闰月或不同流派需要统一口径时再手动覆写。每个下拉选项后都直接标注对应的阳历提示。</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={manualMode ? 'switch switch-on' : 'switch'}
|
||||||
|
onClick={() => setManualMode((value) => !value)}
|
||||||
|
aria-pressed={manualMode}
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={manualMode ? 'branch-editor branch-editor-open' : 'branch-editor'}>
|
||||||
|
{(['year', 'month', 'day', 'time'] as BranchSlot[]).map((slot) => (
|
||||||
|
<label key={slot} className="field branch-field">
|
||||||
|
<span>{SLOT_LABELS[slot]}</span>
|
||||||
|
<select
|
||||||
|
value={resolveBranchValue(slot, overrides, previewResult)}
|
||||||
|
onChange={(event) =>
|
||||||
|
setOverrides((current) => ({
|
||||||
|
...current,
|
||||||
|
[slot]: event.target.value as BranchName,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!manualMode}
|
||||||
|
>
|
||||||
|
{branchOptionLabels.map((item) => (
|
||||||
|
<option key={`${slot}-${item.branch}`} value={item.branch}>
|
||||||
|
{item[slot]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<small className="branch-note">
|
||||||
|
当前所取:{currentBranchHints[slot]}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="compass-panel">
|
||||||
|
<div className="compass-head">
|
||||||
|
<div>
|
||||||
|
<p>罗盘三数</p>
|
||||||
|
<span>输入三个 1 到 9 的随机数。第一数定外势,第二数看过程,第三数落执行,三数合参定终局。</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ghost-action ghost-action-inline" onClick={fillRandomCompassNumbers}>
|
||||||
|
随机取数
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compass-grid">
|
||||||
|
{([
|
||||||
|
['first', '第一数'],
|
||||||
|
['second', '第二数'],
|
||||||
|
['third', '第三数'],
|
||||||
|
] as const).map(([key, label]) => (
|
||||||
|
<label key={key} className="field">
|
||||||
|
<span>{label}</span>
|
||||||
|
<select
|
||||||
|
value={String(compassNumbers[key])}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCompassNumbers((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: Number(event.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{compassNumberOptions.map((value) => (
|
||||||
|
<option key={`${key}-${value}`} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="compass-note">
|
||||||
|
当前合参:{compassNumbers.first + compassNumbers.second + compassNumbers.third},将作为终宫顺推步数。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="solid-action" onClick={() => void handleCast()} disabled={isAiLoading}>
|
||||||
|
{isAiLoading ? '排盘解卦中...' : '落挂牌盘'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost-action" onClick={handleCopy}>
|
||||||
|
拓印归档
|
||||||
|
</button>
|
||||||
|
{actionHint ? <span className="status-hint">{actionHint}</span> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="ritual-aside">
|
||||||
|
<div className="aside-block">
|
||||||
|
<p className="aside-label">起课方式</p>
|
||||||
|
<h3>{previewResult.modeLabel}</h3>
|
||||||
|
<span>{previewResult.methodNote}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aside-block">
|
||||||
|
<p className="aside-label">{mode === 'time' ? '当前时令' : '请示时刻'}</p>
|
||||||
|
<h3>{previewResult.solarLabel}</h3>
|
||||||
|
<span>{previewResult.lunarLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aside-block">
|
||||||
|
<p className="aside-label">即时卦气</p>
|
||||||
|
<h3>{previewResult.localInterpretation.verdict}</h3>
|
||||||
|
<span>{previewResult.localInterpretation.summary}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aside-grid">
|
||||||
|
{previewResult.palaces.map((item) => (
|
||||||
|
<article key={item.slot} className="aside-node">
|
||||||
|
<p>{item.label}</p>
|
||||||
|
<strong>{item.palace}</strong>
|
||||||
|
<span>
|
||||||
|
{item.branch} · {item.element}
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="reading" id="reading">
|
||||||
|
<div className="section-head">
|
||||||
|
<p className="section-kicker">第二步</p>
|
||||||
|
<h2>解断要把“格局、次第、落点”同时讲清</h2>
|
||||||
|
<p className="section-summary">
|
||||||
|
左侧看四宫仪盘与生克链路,右侧看总断与行动策略。无论你选哪种起课方式,第二步都统一落到同一套断盘结构。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="reading-shell">
|
||||||
|
<section className="reading-board">
|
||||||
|
<div className="board-stage">
|
||||||
|
<div className="board-axis board-axis-vertical"></div>
|
||||||
|
<div className="board-axis board-axis-horizontal"></div>
|
||||||
|
<div className="board-ring board-ring-outer"></div>
|
||||||
|
<div className="board-ring board-ring-middle"></div>
|
||||||
|
<div className="board-ring board-ring-inner"></div>
|
||||||
|
<div className="board-direction board-direction-top">年势</div>
|
||||||
|
<div className="board-direction board-direction-right">月令</div>
|
||||||
|
<div className="board-direction board-direction-bottom">日用</div>
|
||||||
|
<div className="board-direction board-direction-left">时落</div>
|
||||||
|
|
||||||
|
{committedResult.palaces.map((item) => (
|
||||||
|
<article key={item.slot} className={`board-node board-node-${item.slot}`}>
|
||||||
|
<p>{item.label}</p>
|
||||||
|
<strong>{item.palace}</strong>
|
||||||
|
<span>
|
||||||
|
{item.branch} · {item.element}
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="board-core">
|
||||||
|
<p>终局所归</p>
|
||||||
|
<strong>{finalPalace.palace}</strong>
|
||||||
|
<span>{committedResult.localInterpretation.verdict}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="board-legend">
|
||||||
|
<article className="board-legend-card">
|
||||||
|
<p>四宫次第</p>
|
||||||
|
<strong>{committedResult.palaces.map((item) => item.palace).join(' → ')}</strong>
|
||||||
|
<span>按年 → 月 → 日 → 时顺推,终宫决定结果落点。</span>
|
||||||
|
</article>
|
||||||
|
<article className="board-legend-card board-legend-card-accent">
|
||||||
|
<p>终宫定性</p>
|
||||||
|
<strong>
|
||||||
|
{finalPalace.palace} · 五行{finalPalace.element}
|
||||||
|
</strong>
|
||||||
|
<span>{finalPalace.theme}</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relation-ribbon">
|
||||||
|
{committedResult.relations.map((item) => (
|
||||||
|
<article key={`${item.from}-${item.to}`} className="relation-chip">
|
||||||
|
<span>{item.kind}</span>
|
||||||
|
<p>{item.summary}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="reading-sheet">
|
||||||
|
<article className="sheet-hero">
|
||||||
|
<div className="sheet-hero-main">
|
||||||
|
<p className="section-kicker">总断</p>
|
||||||
|
<h3>{committedResult.localInterpretation.verdict}</h3>
|
||||||
|
<p>{committedResult.localInterpretation.summary}</p>
|
||||||
|
</div>
|
||||||
|
<div className="sheet-hero-side">
|
||||||
|
<div className="sheet-hero-tag">{committedResult.modeLabel}</div>
|
||||||
|
<div className="sheet-hero-tag">终宫 {finalPalace.palace}</div>
|
||||||
|
<div className="sheet-hero-tag">评分 {committedResult.localInterpretation.score}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div className="sheet-grid">
|
||||||
|
{committedResult.palaces.map((item) => (
|
||||||
|
<article key={item.slot} className="sheet-card">
|
||||||
|
<div className="sheet-card-head">
|
||||||
|
<p>{item.label}</p>
|
||||||
|
<strong>{item.palace}</strong>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
{item.branch} · 五行{item.element}
|
||||||
|
</span>
|
||||||
|
<p>{buildPalaceInterpretation(item)}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sheet-analysis">
|
||||||
|
<article className="summary-card summary-card-light">
|
||||||
|
<p className="section-kicker">卦势链路</p>
|
||||||
|
<p>{relationSummary || '当前未形成可展示的生克链路。'}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="advice-card advice-card-steps">
|
||||||
|
<div className="advice-head">
|
||||||
|
<h3>趋吉次第</h3>
|
||||||
|
<span>按“先看势,再落点,后执行”的顺序处理。</span>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{committedResult.localInterpretation.actionAdvice.map((item, index) => (
|
||||||
|
<li key={item} className="advice-step">
|
||||||
|
<strong>{STRATEGY_LABELS[index] ?? `次第${index + 1}`}</strong>
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="advice-card advice-card-risk">
|
||||||
|
<div className="advice-head">
|
||||||
|
<h3>避忌要点</h3>
|
||||||
|
<span>这些地方最容易让局面走偏。</span>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{committedResult.localInterpretation.riskAdvice.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="oracle" id="oracle" ref={oracleRef}>
|
||||||
|
<div className="section-head">
|
||||||
|
<p className="section-kicker">第三步</p>
|
||||||
|
<h2>AI 详解与图片报告</h2>
|
||||||
|
<p className="section-summary">
|
||||||
|
点击“落挂牌盘”后会自动跳到这里并开始解卦。后端统一负责提示词和格式约束,前端只负责结构化渲染与图片报告导出。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="oracle-panel">
|
||||||
|
<div className="oracle-toolbar">
|
||||||
|
<div className="oracle-output-head">
|
||||||
|
<p className="section-kicker">AI 解卦</p>
|
||||||
|
<span>{isAiLoading ? '正在观象' : aiInterpretation ? '结果已生成' : '静候请示'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="oracle-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-action"
|
||||||
|
onClick={() => void handleRetryAi()}
|
||||||
|
disabled={isAiLoading}
|
||||||
|
>
|
||||||
|
{isAiLoading ? '解卦中...' : '重新请示 AI'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-action"
|
||||||
|
onClick={() => void handleExportReport()}
|
||||||
|
disabled={isExporting}
|
||||||
|
>
|
||||||
|
{isExporting ? '导出中...' : '导出图片报告'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="oracle-summary oracle-summary-quad">
|
||||||
|
<article>
|
||||||
|
<p>起课</p>
|
||||||
|
<strong>{committedResult.modeLabel}</strong>
|
||||||
|
<span>{committedResult.methodNote}</span>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<p>终宫</p>
|
||||||
|
<strong>{finalPalace.palace}</strong>
|
||||||
|
<span>{finalPalace.theme}</span>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<p>总断</p>
|
||||||
|
<strong>{committedResult.localInterpretation.verdict}</strong>
|
||||||
|
<span>{committedResult.localInterpretation.domainLabel}</span>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<p>导出</p>
|
||||||
|
<strong>总结海报</strong>
|
||||||
|
<span>包含四宫、次第、风险与 AI 摘录。</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiInterpretation ? (
|
||||||
|
<article className="oracle-scroll">
|
||||||
|
{aiBlocks.map((block, index) => {
|
||||||
|
if (block.type === 'divider') {
|
||||||
|
return <div key={`divider-${index}`} className="ai-divider"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'heading') {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={`${block.rawText}-${index}`}
|
||||||
|
className={`ai-block ai-block-heading ai-block-heading-${Math.min(block.level, 4)}`}
|
||||||
|
>
|
||||||
|
{block.level >= 4 ? <h4>{renderInlineTokens(block.tokens)}</h4> : <h3>{renderInlineTokens(block.tokens)}</h3>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'list') {
|
||||||
|
return (
|
||||||
|
<section key={`list-${index}`} className="ai-block ai-block-list">
|
||||||
|
{block.ordered ? (
|
||||||
|
<ol>
|
||||||
|
{block.items.map((item, itemIndex) => (
|
||||||
|
<li key={`${item.rawText}-${itemIndex}`}>
|
||||||
|
<span className="ai-list-index">{String(itemIndex + 1).padStart(2, '0')}</span>
|
||||||
|
<p>{renderInlineTokens(item.tokens)}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{block.items.map((item) => (
|
||||||
|
<li key={item.rawText}>
|
||||||
|
<p>{renderInlineTokens(item.tokens)}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={`${block.rawText}-${index}`}
|
||||||
|
className={index === 0 ? 'ai-block ai-block-lead' : 'ai-block'}
|
||||||
|
>
|
||||||
|
<p>{renderInlineTokens(block.tokens)}</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</article>
|
||||||
|
) : (
|
||||||
|
<article className="oracle-empty">
|
||||||
|
<p>当前还没有 AI 输出。</p>
|
||||||
|
<p>请点击上方“落挂牌盘”,系统会在排盘后自动开始解卦。</p>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aiError ? <p className="status-hint status-error">{aiError}</p> : null}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBranchValue(
|
||||||
|
slot: BranchSlot,
|
||||||
|
overrides: ManualBranchOverrides,
|
||||||
|
result: DivinationResult,
|
||||||
|
): BranchName {
|
||||||
|
return (overrides[slot] ?? result.branches[slot].branch) as BranchName
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineTokens(tokens: AiInlineToken[]) {
|
||||||
|
return tokens.map((token, index) =>
|
||||||
|
token.type === 'strong' ? (
|
||||||
|
<strong key={`${token.text}-${index}`} className="ai-inline-strong">
|
||||||
|
{token.text}
|
||||||
|
</strong>
|
||||||
|
) : (
|
||||||
|
<span key={`${token.text}-${index}`}>{token.text}</span>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomCompassNumber(): number {
|
||||||
|
return Math.floor(Math.random() * 9) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCurrentBranchHint(slot: BranchSlot, branch: BranchName, datetime: string): string {
|
||||||
|
if (slot === 'time') {
|
||||||
|
return `${branch} 对应 ${BRANCH_TIME_HINT[branch]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slot === 'month') {
|
||||||
|
return `${branch} 对应 ${BRANCH_MONTH_HINT[branch]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slot === 'year') {
|
||||||
|
return `${branch} 年近例:${buildYearExamples(branch, datetime)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${branch} 在本月对应:${buildDayExamples(branch, datetime)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildYearExamples(branch: BranchName, datetime: string): string {
|
||||||
|
const date = parseLocalDatetime(datetime)
|
||||||
|
const currentYear = date.getFullYear()
|
||||||
|
const matches: number[] = []
|
||||||
|
|
||||||
|
for (let year = currentYear - 24; year <= currentYear + 24; year += 1) {
|
||||||
|
const zhi = Solar.fromYmd(year, 7, 1).getLunar().getYearZhi() as BranchName
|
||||||
|
if (zhi === branch) {
|
||||||
|
matches.push(year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nearby = matches
|
||||||
|
.sort((left, right) => Math.abs(left - currentYear) - Math.abs(right - currentYear))
|
||||||
|
.slice(0, 3)
|
||||||
|
.sort((left, right) => left - right)
|
||||||
|
|
||||||
|
return nearby.join(' / ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDayExamples(branch: BranchName, datetime: string): string {
|
||||||
|
const date = parseLocalDatetime(datetime)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth() + 1
|
||||||
|
const lastDay = new Date(year, month, 0).getDate()
|
||||||
|
const matches: string[] = []
|
||||||
|
|
||||||
|
for (let day = 1; day <= lastDay; day += 1) {
|
||||||
|
const dayBranch = Solar.fromYmd(year, month, day).getLunar().getDayZhi() as BranchName
|
||||||
|
if (dayBranch === branch) {
|
||||||
|
matches.push(`${String(month).padStart(2, '0')}/${String(day).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.join('、')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDatetime(value: string): Date {
|
||||||
|
const [datePart, timePart = '00:00'] = value.split('T')
|
||||||
|
const [year, month, day] = datePart.split('-').map(Number)
|
||||||
|
const [hour, minute] = timePart.split(':').map(Number)
|
||||||
|
|
||||||
|
if (![year, month, day, hour, minute].every(Number.isFinite)) {
|
||||||
|
return new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(year, month - 1, day, hour, minute, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPalaceInterpretation(item: PalaceResult): string {
|
||||||
|
return `${item.theme}${item.detail}${item.slotExplanation}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
BIN
web/src/assets/hero.png
Normal file
BIN
web/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
web/src/assets/react.svg
Normal file
1
web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
web/src/assets/vite.svg
Normal file
1
web/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
110
web/src/index.css
Normal file
110
web/src/index.css
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;600;700&family=ZCOOL+XiaoWei&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #070a10;
|
||||||
|
--bg-elevated: #0d121a;
|
||||||
|
--ink: #d5ccb8;
|
||||||
|
--ink-soft: #a89e8a;
|
||||||
|
--ink-bright: #f4ead8;
|
||||||
|
--gold: #cda15a;
|
||||||
|
--gold-soft: #d5ba84;
|
||||||
|
--cinnabar-soft: #d48d79;
|
||||||
|
--serif: 'Noto Serif SC', 'Songti SC', serif;
|
||||||
|
--display: 'ZCOOL XiaoWei', 'Noto Serif SC', serif;
|
||||||
|
font-family: var(--serif);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 18%, rgba(138, 28, 18, 0.12), transparent 24%),
|
||||||
|
radial-gradient(circle at 82% 16%, rgba(199, 160, 94, 0.08), transparent 20%),
|
||||||
|
linear-gradient(180deg, #090c12, #070a10 34%, #090c12 100%);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.015), rgba(255, 255, 255, 0)),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0,
|
||||||
|
transparent 108px,
|
||||||
|
rgba(255, 245, 222, 0.018) 108px,
|
||||||
|
rgba(255, 245, 222, 0.018) 109px
|
||||||
|
),
|
||||||
|
radial-gradient(circle at 50% -10%, rgba(221, 179, 98, 0.08), transparent 32%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 22% 20%, rgba(255, 248, 232, 0.04), transparent 14%),
|
||||||
|
radial-gradient(circle at 76% 14%, rgba(193, 151, 73, 0.05), transparent 16%);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
p,
|
||||||
|
ul {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
color: var(--ink-bright);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
li,
|
||||||
|
label,
|
||||||
|
span,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
font-family: var(--serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(205, 161, 90, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
215
web/src/lib/ai-render.ts
Normal file
215
web/src/lib/ai-render.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
export interface AiInlineToken {
|
||||||
|
type: 'text' | 'strong'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiRenderBlock =
|
||||||
|
| { type: 'heading'; level: number; tokens: AiInlineToken[]; rawText: string }
|
||||||
|
| { type: 'paragraph'; tokens: AiInlineToken[]; rawText: string }
|
||||||
|
| { type: 'list'; ordered: boolean; items: { tokens: AiInlineToken[]; rawText: string }[] }
|
||||||
|
| { type: 'divider' }
|
||||||
|
|
||||||
|
export function parseAiOutput(text: string): AiRenderBlock[] {
|
||||||
|
if (!text.trim()) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = text.replace(/\r\n/g, '\n')
|
||||||
|
const lines = normalizedText.split('\n')
|
||||||
|
const blocks: AiRenderBlock[] = []
|
||||||
|
const paragraphBuffer: string[] = []
|
||||||
|
let listBuffer: { ordered: boolean; items: { tokens: AiInlineToken[]; rawText: string }[] } | null = null
|
||||||
|
|
||||||
|
const flushParagraph = () => {
|
||||||
|
if (paragraphBuffer.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = paragraphBuffer.join(' ').replace(/\s+/g, ' ').trim()
|
||||||
|
if (text) {
|
||||||
|
blocks.push({
|
||||||
|
type: 'paragraph',
|
||||||
|
tokens: parseInlineTokens(cleanInlineSyntax(text)),
|
||||||
|
rawText: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
paragraphBuffer.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushList = () => {
|
||||||
|
if (!listBuffer || listBuffer.items.length === 0) {
|
||||||
|
listBuffer = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
type: 'list',
|
||||||
|
ordered: listBuffer.ordered,
|
||||||
|
items: listBuffer.items,
|
||||||
|
})
|
||||||
|
listBuffer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
flushParagraph()
|
||||||
|
flushList()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line)) {
|
||||||
|
flushParagraph()
|
||||||
|
flushList()
|
||||||
|
blocks.push({ type: 'divider' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/)
|
||||||
|
if (headingMatch) {
|
||||||
|
flushParagraph()
|
||||||
|
flushList()
|
||||||
|
const rawText = cleanInlineSyntax(headingMatch[2].trim())
|
||||||
|
blocks.push({
|
||||||
|
type: 'heading',
|
||||||
|
level: headingMatch[1].length,
|
||||||
|
tokens: parseInlineTokens(rawText),
|
||||||
|
rawText,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedMatch = line.match(/^(\d+)[.)]\s+(.+)$/)
|
||||||
|
if (orderedMatch) {
|
||||||
|
flushParagraph()
|
||||||
|
const itemText = cleanInlineSyntax(orderedMatch[2].trim())
|
||||||
|
if (!listBuffer || !listBuffer.ordered) {
|
||||||
|
flushList()
|
||||||
|
listBuffer = {
|
||||||
|
ordered: true,
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listBuffer.items.push({
|
||||||
|
tokens: parseInlineTokens(itemText),
|
||||||
|
rawText: itemText,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const unorderedMatch = line.match(/^[-*•]\s+(.+)$/)
|
||||||
|
if (unorderedMatch) {
|
||||||
|
flushParagraph()
|
||||||
|
const itemText = cleanInlineSyntax(unorderedMatch[1].trim())
|
||||||
|
if (!listBuffer || listBuffer.ordered) {
|
||||||
|
flushList()
|
||||||
|
listBuffer = {
|
||||||
|
ordered: false,
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listBuffer.items.push({
|
||||||
|
tokens: parseInlineTokens(itemText),
|
||||||
|
rawText: itemText,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const syntheticHeading = matchSyntheticHeading(line)
|
||||||
|
if (syntheticHeading) {
|
||||||
|
flushParagraph()
|
||||||
|
flushList()
|
||||||
|
blocks.push({
|
||||||
|
type: 'heading',
|
||||||
|
level: syntheticHeading.level,
|
||||||
|
tokens: parseInlineTokens(syntheticHeading.text),
|
||||||
|
rawText: syntheticHeading.text,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
flushList()
|
||||||
|
paragraphBuffer.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushParagraph()
|
||||||
|
flushList()
|
||||||
|
|
||||||
|
return normalizeBlocks(blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInlineTokens(text: string): AiInlineToken[] {
|
||||||
|
const tokens: AiInlineToken[] = []
|
||||||
|
const pattern = /\*\*(.+?)\*\*/g
|
||||||
|
let lastIndex = 0
|
||||||
|
|
||||||
|
for (const match of text.matchAll(pattern)) {
|
||||||
|
const index = match.index ?? 0
|
||||||
|
if (index > lastIndex) {
|
||||||
|
tokens.push({
|
||||||
|
type: 'text',
|
||||||
|
text: text.slice(lastIndex, index),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.push({
|
||||||
|
type: 'strong',
|
||||||
|
text: match[1],
|
||||||
|
})
|
||||||
|
lastIndex = index + match[0].length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
tokens.push({
|
||||||
|
type: 'text',
|
||||||
|
text: text.slice(lastIndex),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return [{ type: 'text', text }]
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens.filter((token) => token.text.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSyntheticHeading(line: string): { level: number; text: string } | null {
|
||||||
|
const normalized = cleanInlineSyntax(line).replace(/[::]$/, '')
|
||||||
|
if (
|
||||||
|
normalized.length <= 22 &&
|
||||||
|
/^(总断|四宫详解与五行生克分析|年宫|月宫|日宫|时宫|生克|趋吉次第|避忌要点|结语|建议|风险|提醒)/.test(
|
||||||
|
normalized,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
level: normalized.includes('年宫') ||
|
||||||
|
normalized.includes('月宫') ||
|
||||||
|
normalized.includes('日宫') ||
|
||||||
|
normalized.includes('时宫')
|
||||||
|
? 4
|
||||||
|
: 3,
|
||||||
|
text: normalized,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanInlineSyntax(text: string): string {
|
||||||
|
return text.replace(/[【】]/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBlocks(blocks: AiRenderBlock[]): AiRenderBlock[] {
|
||||||
|
return blocks.filter((block, index) => {
|
||||||
|
if (block.type === 'paragraph' && block.rawText === '--') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'divider' && index > 0 && blocks[index - 1]?.type === 'divider') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
105
web/src/lib/ai.ts
Normal file
105
web/src/lib/ai.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { DivinationResult } from './divination.ts'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__XLY_CONFIG__?: {
|
||||||
|
VITE_API_BASE_URL?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiServerStatus {
|
||||||
|
enabled: boolean
|
||||||
|
providerName: string
|
||||||
|
model: string
|
||||||
|
mode: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiEnvelope<T> {
|
||||||
|
data?: T
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = resolveApiBaseUrl()
|
||||||
|
|
||||||
|
export async function fetchAiStatus(): Promise<AiServerStatus> {
|
||||||
|
const response = await fetch(buildApiUrl('/api/v1/ai/status'))
|
||||||
|
const payload = (await response.json().catch(() => ({}))) as ApiEnvelope<AiServerStatus>
|
||||||
|
|
||||||
|
if (!response.ok || !payload.data) {
|
||||||
|
throw new Error(payload.message || '读取 AI 服务状态失败。')
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestAiInterpretation(result: DivinationResult): Promise<string> {
|
||||||
|
const response = await fetch(buildApiUrl('/api/v1/ai/interpret'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(buildInterpretRequest(result)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => ({}))) as ApiEnvelope<{ content: string }>
|
||||||
|
|
||||||
|
if (!response.ok || !payload.data?.content) {
|
||||||
|
throw new Error(payload.message || 'AI 解卦失败,请检查后端服务配置。')
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.data.content
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApiUrl(path: string): string {
|
||||||
|
return API_BASE_URL ? `${API_BASE_URL}${path}` : path
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiBaseUrl(): string {
|
||||||
|
const runtimeValue = window.__XLY_CONFIG__?.VITE_API_BASE_URL ?? ''
|
||||||
|
const buildValue = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||||
|
return String(runtimeValue || buildValue).replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInterpretRequest(result: DivinationResult) {
|
||||||
|
return {
|
||||||
|
mode: result.mode,
|
||||||
|
modeLabel: result.modeLabel,
|
||||||
|
question: result.question,
|
||||||
|
solarLabel: result.solarLabel,
|
||||||
|
lunarLabel: result.lunarLabel,
|
||||||
|
methodNote: result.methodNote,
|
||||||
|
finalPalace: result.finalPalace.palace,
|
||||||
|
finalElement: result.finalPalace.element,
|
||||||
|
slots: result.palaces.map((palace) => {
|
||||||
|
const branch = result.branches[palace.slot]
|
||||||
|
return {
|
||||||
|
label: palace.label,
|
||||||
|
token: palace.branch,
|
||||||
|
value: palace.branchValue,
|
||||||
|
source: branch.source,
|
||||||
|
sourceNote: branch.sourceNote,
|
||||||
|
ganZhi: branch.ganZhi,
|
||||||
|
palace: palace.palace,
|
||||||
|
element: palace.element,
|
||||||
|
theme: palace.theme,
|
||||||
|
detail: palace.detail,
|
||||||
|
slotExplanation: palace.slotExplanation,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
relations: result.relations.map((relation) => ({
|
||||||
|
kind: relation.kind,
|
||||||
|
summary: relation.summary,
|
||||||
|
})),
|
||||||
|
localInterpretation: {
|
||||||
|
verdict: result.localInterpretation.verdict,
|
||||||
|
score: result.localInterpretation.score,
|
||||||
|
summary: result.localInterpretation.summary,
|
||||||
|
domainLabel: result.localInterpretation.domainLabel,
|
||||||
|
chainSummary: result.localInterpretation.chainSummary,
|
||||||
|
actionAdvice: result.localInterpretation.actionAdvice,
|
||||||
|
riskAdvice: result.localInterpretation.riskAdvice,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
708
web/src/lib/divination.ts
Normal file
708
web/src/lib/divination.ts
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
import { Solar } from 'lunar-typescript'
|
||||||
|
|
||||||
|
const PALACE_ORDER = ['大安', '留连', '速喜', '赤口', '小吉', '空亡'] as const
|
||||||
|
const BRANCH_ORDER = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'] as const
|
||||||
|
const LUNAR_MONTH_BRANCHES = ['寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥', '子', '丑'] as const
|
||||||
|
const SLOT_ORDER = ['year', 'month', 'day', 'time'] as const
|
||||||
|
const COMPASS_SLOT_VALUES = ['first', 'second', 'third'] as const
|
||||||
|
|
||||||
|
const BRANCH_VALUES: Record<BranchName, number> = {
|
||||||
|
子: 1,
|
||||||
|
丑: 2,
|
||||||
|
寅: 3,
|
||||||
|
卯: 4,
|
||||||
|
辰: 5,
|
||||||
|
巳: 6,
|
||||||
|
午: 7,
|
||||||
|
未: 8,
|
||||||
|
申: 9,
|
||||||
|
酉: 10,
|
||||||
|
戌: 11,
|
||||||
|
亥: 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PALACE_META = {
|
||||||
|
大安: {
|
||||||
|
element: '木',
|
||||||
|
quality: '吉',
|
||||||
|
theme: '稳住格局,利于正面推进。',
|
||||||
|
detail: '适合谈条件、做决策、走正式流程。',
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
留连: {
|
||||||
|
element: '土',
|
||||||
|
quality: '中',
|
||||||
|
theme: '反复拖延,过程比结果更磨人。',
|
||||||
|
detail: '适合补材料、等批复、拉齐分歧,不宜急催。',
|
||||||
|
weight: 0,
|
||||||
|
},
|
||||||
|
速喜: {
|
||||||
|
element: '火',
|
||||||
|
quality: '吉',
|
||||||
|
theme: '消息来得快,适合主动推进。',
|
||||||
|
detail: '利沟通、利反馈、利敲定窗口期。',
|
||||||
|
weight: 3,
|
||||||
|
},
|
||||||
|
赤口: {
|
||||||
|
element: '金',
|
||||||
|
quality: '凶',
|
||||||
|
theme: '口舌争执,信息容易带刺。',
|
||||||
|
detail: '要管住措辞,避免硬碰硬。',
|
||||||
|
weight: -2,
|
||||||
|
},
|
||||||
|
小吉: {
|
||||||
|
element: '水',
|
||||||
|
quality: '吉',
|
||||||
|
theme: '缓步有利,贵在借力。',
|
||||||
|
detail: '利协商、利求人、利柔性处理。',
|
||||||
|
weight: 2,
|
||||||
|
},
|
||||||
|
空亡: {
|
||||||
|
element: '土',
|
||||||
|
quality: '凶',
|
||||||
|
theme: '预期容易落空,先核实条件。',
|
||||||
|
detail: '适合先确认资源和边界,再决定要不要投入。',
|
||||||
|
weight: -3,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const ELEMENT_GENERATES: Record<ElementName, ElementName> = {
|
||||||
|
木: '火',
|
||||||
|
火: '土',
|
||||||
|
土: '金',
|
||||||
|
金: '水',
|
||||||
|
水: '木',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ELEMENT_CONTROLS: Record<ElementName, ElementName> = {
|
||||||
|
木: '土',
|
||||||
|
火: '金',
|
||||||
|
土: '水',
|
||||||
|
金: '木',
|
||||||
|
水: '火',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOT_LABELS: Record<BranchSlot, string> = {
|
||||||
|
year: '年宫',
|
||||||
|
month: '月宫',
|
||||||
|
day: '日宫',
|
||||||
|
time: '时宫',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOT_SOURCES: Record<BranchSlot, string> = {
|
||||||
|
year: '农历年支',
|
||||||
|
month: '农历月建',
|
||||||
|
day: '万年历日支',
|
||||||
|
time: '时辰地支',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOT_EXPLANATIONS: Record<BranchSlot, string> = {
|
||||||
|
year: '对应大环境、外部周期、行业与权力方态度。',
|
||||||
|
month: '对应当月氛围、资源松紧、团队与预算状态。',
|
||||||
|
day: '对应当天执行面、你的状态、沟通与临场表现。',
|
||||||
|
time: '对应结果落点,成败判断以此为主。',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOMAIN_KEYWORDS = {
|
||||||
|
career: ['工作', '加薪', '升职', 'offer', '面试', '跳槽', '裁员', '项目', '合作', '老板'],
|
||||||
|
wealth: ['财运', '投资', '赚钱', '回款', '签单', '收入', '生意', '客户'],
|
||||||
|
relation: ['感情', '恋爱', '复合', '结婚', '相亲', '关系', '伴侣'],
|
||||||
|
study: ['考试', '学习', '申请', '留学', '证书', '答辩'],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PalaceName = (typeof PALACE_ORDER)[number]
|
||||||
|
export type BranchName = (typeof BRANCH_ORDER)[number]
|
||||||
|
export type BranchSlot = (typeof SLOT_ORDER)[number]
|
||||||
|
export type ElementName = '木' | '火' | '土' | '金' | '水'
|
||||||
|
export type DivinationMode = 'time' | 'compass'
|
||||||
|
|
||||||
|
export interface CompassNumbers {
|
||||||
|
first: number
|
||||||
|
second: number
|
||||||
|
third: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualBranchOverrides {
|
||||||
|
year?: BranchName
|
||||||
|
month?: BranchName
|
||||||
|
day?: BranchName
|
||||||
|
time?: BranchName
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchInfo {
|
||||||
|
slot: BranchSlot
|
||||||
|
label: string
|
||||||
|
branch: string
|
||||||
|
value: number
|
||||||
|
source: string
|
||||||
|
sourceNote: string
|
||||||
|
ganZhi: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PalaceResult {
|
||||||
|
slot: BranchSlot
|
||||||
|
label: string
|
||||||
|
branch: string
|
||||||
|
branchValue: number
|
||||||
|
palace: PalaceName
|
||||||
|
element: ElementName
|
||||||
|
quality: '吉' | '中' | '凶'
|
||||||
|
theme: string
|
||||||
|
detail: string
|
||||||
|
slotExplanation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PalaceRelation {
|
||||||
|
from: BranchSlot
|
||||||
|
to: BranchSlot
|
||||||
|
kind: '前生后' | '前克后' | '后生前' | '后克前' | '比和'
|
||||||
|
score: number
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalInterpretation {
|
||||||
|
verdict: string
|
||||||
|
score: number
|
||||||
|
summary: string
|
||||||
|
domainLabel: string
|
||||||
|
chainSummary: string
|
||||||
|
actionAdvice: string[]
|
||||||
|
riskAdvice: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DivinationResult {
|
||||||
|
mode: DivinationMode
|
||||||
|
modeLabel: string
|
||||||
|
question: string
|
||||||
|
datetime: string
|
||||||
|
solarLabel: string
|
||||||
|
lunarLabel: string
|
||||||
|
methodNote: string
|
||||||
|
branches: Record<BranchSlot, BranchInfo>
|
||||||
|
palaces: PalaceResult[]
|
||||||
|
relations: PalaceRelation[]
|
||||||
|
finalPalace: PalaceResult
|
||||||
|
localInterpretation: LocalInterpretation
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DerivedContext {
|
||||||
|
mode: DivinationMode
|
||||||
|
modeLabel: string
|
||||||
|
solarLabel: string
|
||||||
|
lunarLabel: string
|
||||||
|
methodNote: string
|
||||||
|
branches: Record<BranchSlot, BranchInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const branchOptions = [...BRANCH_ORDER]
|
||||||
|
export const compassNumberOptions = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const
|
||||||
|
export const defaultCompassNumbers: CompassNumbers = {
|
||||||
|
first: 3,
|
||||||
|
second: 6,
|
||||||
|
third: 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDivination(input: {
|
||||||
|
datetime: string
|
||||||
|
question: string
|
||||||
|
mode?: DivinationMode
|
||||||
|
overrides?: ManualBranchOverrides
|
||||||
|
compassNumbers?: CompassNumbers
|
||||||
|
}): DivinationResult {
|
||||||
|
const trimmedQuestion = input.question.trim() || '这件事现在推进,结果会怎样?'
|
||||||
|
const context = deriveContext({
|
||||||
|
mode: input.mode ?? 'time',
|
||||||
|
datetime: input.datetime,
|
||||||
|
overrides: input.overrides,
|
||||||
|
compassNumbers: input.compassNumbers,
|
||||||
|
})
|
||||||
|
const palaces = buildPalaces(context.branches)
|
||||||
|
const relations = buildRelations(palaces)
|
||||||
|
const finalPalace = palaces[palaces.length - 1]
|
||||||
|
const localInterpretation = buildLocalInterpretation(trimmedQuestion, palaces, relations)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: context.mode,
|
||||||
|
modeLabel: context.modeLabel,
|
||||||
|
question: trimmedQuestion,
|
||||||
|
datetime: input.datetime,
|
||||||
|
solarLabel: context.solarLabel,
|
||||||
|
lunarLabel: context.lunarLabel,
|
||||||
|
methodNote: context.methodNote,
|
||||||
|
branches: context.branches,
|
||||||
|
palaces,
|
||||||
|
relations,
|
||||||
|
finalPalace,
|
||||||
|
localInterpretation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveBranchDefaults(datetime: string): Record<BranchSlot, BranchName> {
|
||||||
|
const branches = deriveTimeContext(datetime).branches
|
||||||
|
return {
|
||||||
|
year: branches.year.branch as BranchName,
|
||||||
|
month: branches.month.branch as BranchName,
|
||||||
|
day: branches.day.branch as BranchName,
|
||||||
|
time: branches.time.branch as BranchName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultDatetimeValue(date = new Date()): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}T${hour}:${minute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveContext(input: {
|
||||||
|
mode: DivinationMode
|
||||||
|
datetime: string
|
||||||
|
overrides?: ManualBranchOverrides
|
||||||
|
compassNumbers?: CompassNumbers
|
||||||
|
}): DerivedContext {
|
||||||
|
if (input.mode === 'compass') {
|
||||||
|
return deriveCompassContext(input.datetime, input.compassNumbers)
|
||||||
|
}
|
||||||
|
|
||||||
|
return deriveTimeContext(input.datetime, input.overrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveTimeContext(datetime: string, overrides?: ManualBranchOverrides): DerivedContext {
|
||||||
|
const date = parseLocalDatetime(datetime)
|
||||||
|
const solar = Solar.fromDate(date)
|
||||||
|
const lunar = solar.getLunar()
|
||||||
|
const lunarMonth = Math.abs(lunar.getMonth())
|
||||||
|
const yearGanZhi = lunar.getYearInGanZhi()
|
||||||
|
const monthGanZhi = lunar.getMonthInGanZhi()
|
||||||
|
const dayGanZhi = lunar.getDayInGanZhi()
|
||||||
|
const timeGanZhi = lunar.getTimeInGanZhi()
|
||||||
|
const autoBranches: Record<BranchSlot, BranchName> = {
|
||||||
|
year: lunar.getYearZhi() as BranchName,
|
||||||
|
month: LUNAR_MONTH_BRANCHES[(Math.max(1, Math.min(12, lunarMonth)) - 1) as number],
|
||||||
|
day: lunar.getDayZhi() as BranchName,
|
||||||
|
time: getTimeBranch(date.getHours()),
|
||||||
|
}
|
||||||
|
const resolvedBranches: Record<BranchSlot, BranchName> = {
|
||||||
|
year: overrides?.year ?? autoBranches.year,
|
||||||
|
month: overrides?.month ?? autoBranches.month,
|
||||||
|
day: overrides?.day ?? autoBranches.day,
|
||||||
|
time: overrides?.time ?? autoBranches.time,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'time',
|
||||||
|
modeLabel: '时刻起课',
|
||||||
|
solarLabel: formatSolarLabel(date),
|
||||||
|
lunarLabel: `农历${lunar.getYearInChinese()}年${lunar.getMonthInChinese()}月${lunar.getDayInChinese()}`,
|
||||||
|
methodNote:
|
||||||
|
'默认按农历年支、农历月建、万年历日支、时辰地支起四宫;若涉及晚子时、闰月或流派差异,可手动覆写。',
|
||||||
|
branches: {
|
||||||
|
year: {
|
||||||
|
slot: 'year',
|
||||||
|
label: SLOT_LABELS.year,
|
||||||
|
branch: resolvedBranches.year,
|
||||||
|
value: BRANCH_VALUES[resolvedBranches.year],
|
||||||
|
source: SLOT_SOURCES.year,
|
||||||
|
sourceNote: `自动换算为 ${yearGanZhi},本法取年支 ${resolvedBranches.year}。`,
|
||||||
|
ganZhi: yearGanZhi,
|
||||||
|
},
|
||||||
|
month: {
|
||||||
|
slot: 'month',
|
||||||
|
label: SLOT_LABELS.month,
|
||||||
|
branch: resolvedBranches.month,
|
||||||
|
value: BRANCH_VALUES[resolvedBranches.month],
|
||||||
|
source: SLOT_SOURCES.month,
|
||||||
|
sourceNote: `当前为农历${lunar.getMonthInChinese()}月,月建取支 ${resolvedBranches.month};干支参考 ${monthGanZhi}。`,
|
||||||
|
ganZhi: monthGanZhi,
|
||||||
|
},
|
||||||
|
day: {
|
||||||
|
slot: 'day',
|
||||||
|
label: SLOT_LABELS.day,
|
||||||
|
branch: resolvedBranches.day,
|
||||||
|
value: BRANCH_VALUES[resolvedBranches.day],
|
||||||
|
source: SLOT_SOURCES.day,
|
||||||
|
sourceNote: `万年历日柱参考 ${dayGanZhi},本法取日支 ${resolvedBranches.day}。`,
|
||||||
|
ganZhi: dayGanZhi,
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
slot: 'time',
|
||||||
|
label: SLOT_LABELS.time,
|
||||||
|
branch: resolvedBranches.time,
|
||||||
|
value: BRANCH_VALUES[resolvedBranches.time],
|
||||||
|
source: SLOT_SOURCES.time,
|
||||||
|
sourceNote: `当前时柱参考 ${timeGanZhi},本法取时支 ${resolvedBranches.time}。`,
|
||||||
|
ganZhi: timeGanZhi,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveCompassContext(datetime: string, numbers?: CompassNumbers): DerivedContext {
|
||||||
|
const date = parseLocalDatetime(datetime)
|
||||||
|
const solar = Solar.fromDate(date)
|
||||||
|
const lunar = solar.getLunar()
|
||||||
|
const resolvedNumbers = normalizeCompassNumbers(numbers)
|
||||||
|
const total = resolvedNumbers.first + resolvedNumbers.second + resolvedNumbers.third
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'compass',
|
||||||
|
modeLabel: '罗盘三数起课',
|
||||||
|
solarLabel: formatSolarLabel(date),
|
||||||
|
lunarLabel: `农历${lunar.getYearInChinese()}年${lunar.getMonthInChinese()}月${lunar.getDayInChinese()}`,
|
||||||
|
methodNote:
|
||||||
|
'按罗盘三数法起课:第一数定外势,第二数看过程,第三数落执行,三数合参定终局。三数都取 1 到 9,结果宫按合参之数顺推。',
|
||||||
|
branches: {
|
||||||
|
year: {
|
||||||
|
slot: 'year',
|
||||||
|
label: SLOT_LABELS.year,
|
||||||
|
branch: `数${resolvedNumbers.first}`,
|
||||||
|
value: resolvedNumbers.first,
|
||||||
|
source: '罗盘第一数',
|
||||||
|
sourceNote: `第一数取 ${resolvedNumbers.first},从大安起数,先定年宫。`,
|
||||||
|
ganZhi: '-',
|
||||||
|
},
|
||||||
|
month: {
|
||||||
|
slot: 'month',
|
||||||
|
label: SLOT_LABELS.month,
|
||||||
|
branch: `数${resolvedNumbers.second}`,
|
||||||
|
value: resolvedNumbers.second,
|
||||||
|
source: '罗盘第二数',
|
||||||
|
sourceNote: `第二数取 ${resolvedNumbers.second},从年宫顺推,落月宫。`,
|
||||||
|
ganZhi: '-',
|
||||||
|
},
|
||||||
|
day: {
|
||||||
|
slot: 'day',
|
||||||
|
label: SLOT_LABELS.day,
|
||||||
|
branch: `数${resolvedNumbers.third}`,
|
||||||
|
value: resolvedNumbers.third,
|
||||||
|
source: '罗盘第三数',
|
||||||
|
sourceNote: `第三数取 ${resolvedNumbers.third},从月宫顺推,落日宫。`,
|
||||||
|
ganZhi: '-',
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
slot: 'time',
|
||||||
|
label: SLOT_LABELS.time,
|
||||||
|
branch: `合${total}`,
|
||||||
|
value: total,
|
||||||
|
source: '三数合参',
|
||||||
|
sourceNote: `三数相加为 ${total},从日宫顺推,定时宫终局。`,
|
||||||
|
ganZhi: '-',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPalaces(branches: Record<BranchSlot, BranchInfo>): PalaceResult[] {
|
||||||
|
let currentIndex = 0
|
||||||
|
|
||||||
|
return SLOT_ORDER.map((slot) => {
|
||||||
|
const branch = branches[slot]
|
||||||
|
currentIndex = walkPalace(currentIndex, branch.value)
|
||||||
|
const palace = PALACE_ORDER[currentIndex]
|
||||||
|
const meta = PALACE_META[palace]
|
||||||
|
|
||||||
|
return {
|
||||||
|
slot,
|
||||||
|
label: SLOT_LABELS[slot],
|
||||||
|
branch: branch.branch,
|
||||||
|
branchValue: branch.value,
|
||||||
|
palace,
|
||||||
|
element: meta.element,
|
||||||
|
quality: meta.quality,
|
||||||
|
theme: meta.theme,
|
||||||
|
detail: meta.detail,
|
||||||
|
slotExplanation: SLOT_EXPLANATIONS[slot],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRelations(palaces: PalaceResult[]): PalaceRelation[] {
|
||||||
|
const relations: PalaceRelation[] = []
|
||||||
|
|
||||||
|
for (let index = 0; index < palaces.length - 1; index += 1) {
|
||||||
|
const current = palaces[index]
|
||||||
|
const next = palaces[index + 1]
|
||||||
|
const relation = classifyRelation(current.element, next.element)
|
||||||
|
|
||||||
|
relations.push({
|
||||||
|
from: current.slot,
|
||||||
|
to: next.slot,
|
||||||
|
kind: relation.kind,
|
||||||
|
score: relation.score,
|
||||||
|
summary: `${current.label}${current.palace}(${current.element})${relation.text}${next.label}${next.palace}(${next.element})`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return relations
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalInterpretation(
|
||||||
|
question: string,
|
||||||
|
palaces: PalaceResult[],
|
||||||
|
relations: PalaceRelation[],
|
||||||
|
): LocalInterpretation {
|
||||||
|
const domainLabel = detectDomain(question)
|
||||||
|
const palaceScore = palaces.reduce((sum, item, index) => {
|
||||||
|
const weight = PALACE_META[item.palace].weight
|
||||||
|
return sum + (index === palaces.length - 1 ? weight * 2 : weight)
|
||||||
|
}, 0)
|
||||||
|
const relationScore = relations.reduce((sum, relation) => sum + relation.score, 0)
|
||||||
|
const score = palaceScore + relationScore
|
||||||
|
const finalPalace = palaces[palaces.length - 1]
|
||||||
|
const verdict = resolveVerdict(score, finalPalace.palace)
|
||||||
|
const chainSummary = relations.map((item) => item.summary).join(';')
|
||||||
|
|
||||||
|
const summary = [
|
||||||
|
`终宫落在${finalPalace.palace},主调是“${PALACE_META[finalPalace.palace].theme}”`,
|
||||||
|
`${finalPalace.label}主结果,说明这件事的落点偏向${describeOutcome(finalPalace.palace)}。`,
|
||||||
|
chainSummary ? `四宫链路里,${chainSummary}。` : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return {
|
||||||
|
verdict,
|
||||||
|
score,
|
||||||
|
summary,
|
||||||
|
domainLabel,
|
||||||
|
chainSummary,
|
||||||
|
actionAdvice: buildActionAdvice(domainLabel, finalPalace, relations),
|
||||||
|
riskAdvice: buildRiskAdvice(finalPalace, relations),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActionAdvice(
|
||||||
|
domainLabel: string,
|
||||||
|
finalPalace: PalaceResult,
|
||||||
|
relations: PalaceRelation[],
|
||||||
|
): string[] {
|
||||||
|
const advice = [
|
||||||
|
'先把所问之事收束成一个判断点,例如现在推进、暂缓观望,还是先补条件。',
|
||||||
|
'先看年宫与月宫给不给势,再决定当下动作,不要只盯终宫一句吉凶。',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (finalPalace.palace === '速喜') {
|
||||||
|
advice.unshift('眼下宜先动一步,把时间、对象和诉求一次定清,不要把顺势拖成失势。')
|
||||||
|
} else if (finalPalace.palace === '大安') {
|
||||||
|
advice.unshift('此局宜走正路,把条件、边界和预期回报摆清,再稳稳推进。')
|
||||||
|
} else if (finalPalace.palace === '小吉') {
|
||||||
|
advice.unshift('此时宜先借力,让熟人、旧成果或中间人替你开路,不必硬闯。')
|
||||||
|
} else if (finalPalace.palace === '留连') {
|
||||||
|
advice.unshift('此局贵在补缺,把顾虑逐条拆开,比一味催结果更有效。')
|
||||||
|
} else if (finalPalace.palace === '赤口') {
|
||||||
|
advice.unshift('此时先收锋芒再出手,话要留余地,事要留凭据。')
|
||||||
|
} else {
|
||||||
|
advice.unshift('此时先验信息真假与承诺落点,未坐实之前,不宜下重注。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domainLabel === '事业/合作') {
|
||||||
|
advice.push('涉及事业或合作时,诉求要落成岗位、预算、周期或资源清单,别只谈感受。')
|
||||||
|
} else if (domainLabel === '财务/生意') {
|
||||||
|
advice.push('涉及财务或生意时,所有判断都要落到现金流、付款条件和违约边界。')
|
||||||
|
} else if (domainLabel === '感情/关系') {
|
||||||
|
advice.push('涉及关系时,先辨对方真实态度,再决定进退,不要拿想象替代反馈。')
|
||||||
|
} else if (domainLabel === '考试/申请') {
|
||||||
|
advice.push('涉及考试或申请时,先守时间规划与材料完整,再谈额外发挥。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relations.some((item) => item.kind === '前生后')) {
|
||||||
|
advice.push('四宫若见顺生,说明次第不可乱,按“先看环境,再落动作,后取结果”会更顺。')
|
||||||
|
}
|
||||||
|
|
||||||
|
return advice.slice(0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRiskAdvice(finalPalace: PalaceResult, relations: PalaceRelation[]): string[] {
|
||||||
|
const advice: string[] = []
|
||||||
|
|
||||||
|
if (relations.some((item) => item.kind === '前克后' || item.kind === '后克前')) {
|
||||||
|
advice.push('四宫存在克制,推进中容易出现抵触、卡口或资源冲突。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalPalace.palace === '赤口') {
|
||||||
|
advice.push('结果宫带赤口,最大的风险不是没有机会,而是把机会说坏。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalPalace.palace === '空亡') {
|
||||||
|
advice.push('结果宫带空亡,最大的风险是信息不实、承诺虚高或条件临时失效。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalPalace.palace === '留连') {
|
||||||
|
advice.push('结果宫带留连,最大的风险是一直等、一直猜,迟迟不做下一步动作。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (advice.length === 0) {
|
||||||
|
advice.push('整体链路不算凶,但仍要把时间点、承诺边界和证据留存好。')
|
||||||
|
}
|
||||||
|
|
||||||
|
return advice.slice(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkPalace(startIndex: number, steps: number): number {
|
||||||
|
return (startIndex + steps - 1) % PALACE_ORDER.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyRelation(from: ElementName, to: ElementName): {
|
||||||
|
kind: PalaceRelation['kind']
|
||||||
|
score: number
|
||||||
|
text: string
|
||||||
|
} {
|
||||||
|
if (from === to) {
|
||||||
|
return { kind: '比和', score: 0, text: '与' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ELEMENT_GENERATES[from] === to) {
|
||||||
|
return { kind: '前生后', score: 1, text: '生' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ELEMENT_CONTROLS[from] === to) {
|
||||||
|
return { kind: '前克后', score: -1, text: '克' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ELEMENT_GENERATES[to] === from) {
|
||||||
|
return { kind: '后生前', score: 0, text: '受' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: '后克前', score: -1, text: '受' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVerdict(score: number, finalPalace: PalaceName): string {
|
||||||
|
if (score >= 7) {
|
||||||
|
return '上吉,可主动推进。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score >= 3) {
|
||||||
|
return '偏吉,成事面大于阻力。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score >= 0) {
|
||||||
|
return '中平,能不能成取决于执行方式。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalPalace === '空亡' || finalPalace === '赤口') {
|
||||||
|
return '偏凶,先控风险再行动。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '有阻,宜放慢节奏并补条件。'
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeOutcome(palace: PalaceName): string {
|
||||||
|
switch (palace) {
|
||||||
|
case '大安':
|
||||||
|
return '稳中见成,适合按规矩落地'
|
||||||
|
case '留连':
|
||||||
|
return '拖中见变,需要耐心和补件'
|
||||||
|
case '速喜':
|
||||||
|
return '快中见成,重在主动出击'
|
||||||
|
case '赤口':
|
||||||
|
return '先有口舌,再看能否化解'
|
||||||
|
case '小吉':
|
||||||
|
return '缓中有利,重在人和与借力'
|
||||||
|
case '空亡':
|
||||||
|
return '容易落空,必须先验真伪'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectDomain(question: string): string {
|
||||||
|
for (const [key, keywords] of Object.entries(DOMAIN_KEYWORDS)) {
|
||||||
|
if (keywords.some((word) => question.includes(word))) {
|
||||||
|
switch (key) {
|
||||||
|
case 'career':
|
||||||
|
return '事业/合作'
|
||||||
|
case 'wealth':
|
||||||
|
return '财务/生意'
|
||||||
|
case 'relation':
|
||||||
|
return '感情/关系'
|
||||||
|
case 'study':
|
||||||
|
return '考试/申请'
|
||||||
|
default:
|
||||||
|
return '综合事项'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '综合事项'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCompassNumbers(numbers?: CompassNumbers): CompassNumbers {
|
||||||
|
const fallback = defaultCompassNumbers
|
||||||
|
const values = {
|
||||||
|
first: numbers?.first ?? fallback.first,
|
||||||
|
second: numbers?.second ?? fallback.second,
|
||||||
|
third: numbers?.third ?? fallback.third,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of COMPASS_SLOT_VALUES) {
|
||||||
|
values[key] = clampCompassNumber(values[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampCompassNumber(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(9, Math.round(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDatetime(value: string): Date {
|
||||||
|
const [datePart, timePart = '00:00'] = value.split('T')
|
||||||
|
const [year, month, day] = datePart.split('-').map((item) => Number(item))
|
||||||
|
const [hour, minute] = timePart.split(':').map((item) => Number(item))
|
||||||
|
|
||||||
|
if (![year, month, day, hour, minute].every(Number.isFinite)) {
|
||||||
|
return new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(year, month - 1, day, hour, minute, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSolarLabel(date: Date): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}年${month}月${day}日 ${hour}:${minute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeBranch(hour: number): BranchName {
|
||||||
|
if (hour >= 23 || hour < 1) {
|
||||||
|
return '子'
|
||||||
|
}
|
||||||
|
if (hour < 3) {
|
||||||
|
return '丑'
|
||||||
|
}
|
||||||
|
if (hour < 5) {
|
||||||
|
return '寅'
|
||||||
|
}
|
||||||
|
if (hour < 7) {
|
||||||
|
return '卯'
|
||||||
|
}
|
||||||
|
if (hour < 9) {
|
||||||
|
return '辰'
|
||||||
|
}
|
||||||
|
if (hour < 11) {
|
||||||
|
return '巳'
|
||||||
|
}
|
||||||
|
if (hour < 13) {
|
||||||
|
return '午'
|
||||||
|
}
|
||||||
|
if (hour < 15) {
|
||||||
|
return '未'
|
||||||
|
}
|
||||||
|
if (hour < 17) {
|
||||||
|
return '申'
|
||||||
|
}
|
||||||
|
if (hour < 19) {
|
||||||
|
return '酉'
|
||||||
|
}
|
||||||
|
if (hour < 21) {
|
||||||
|
return '戌'
|
||||||
|
}
|
||||||
|
return '亥'
|
||||||
|
}
|
||||||
481
web/src/lib/report.ts
Normal file
481
web/src/lib/report.ts
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
import type { DivinationResult } from './divination.ts'
|
||||||
|
|
||||||
|
interface ExportReportInput {
|
||||||
|
result: DivinationResult
|
||||||
|
aiInterpretation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WrappedSection {
|
||||||
|
title: string
|
||||||
|
lines: string[]
|
||||||
|
accent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPORT_WIDTH = 1242
|
||||||
|
const REPORT_PADDING = 72
|
||||||
|
const REPORT_GAP = 20
|
||||||
|
const SECTION_LINE_HEIGHT = 34
|
||||||
|
const SECTION_TEXT_SIZE = 24
|
||||||
|
|
||||||
|
export async function exportReportImage(input: ExportReportInput): Promise<void> {
|
||||||
|
const measurementCanvas = document.createElement('canvas')
|
||||||
|
const measureCtx = measurementCanvas.getContext('2d')
|
||||||
|
if (!measureCtx) {
|
||||||
|
throw new Error('无法创建图片画布')
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentWidth = REPORT_WIDTH - REPORT_PADDING * 2
|
||||||
|
const sections = buildSections(measureCtx, input, contentWidth)
|
||||||
|
const questionLines = wrapLines(measureCtx, `所问:${input.result.question}`, contentWidth, 30, '500')
|
||||||
|
const relationLines = wrapLines(
|
||||||
|
measureCtx,
|
||||||
|
input.result.localInterpretation.chainSummary || '当前未形成可展示的生克链路。',
|
||||||
|
contentWidth - 52,
|
||||||
|
22,
|
||||||
|
)
|
||||||
|
|
||||||
|
const reportHeight = Math.max(
|
||||||
|
measurePosterHeight({
|
||||||
|
questionLines,
|
||||||
|
relationLines,
|
||||||
|
sections,
|
||||||
|
}),
|
||||||
|
1680,
|
||||||
|
)
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const scale = window.devicePixelRatio || 1
|
||||||
|
canvas.width = REPORT_WIDTH * scale
|
||||||
|
canvas.height = reportHeight * scale
|
||||||
|
canvas.style.width = `${REPORT_WIDTH}px`
|
||||||
|
canvas.style.height = `${reportHeight}px`
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('无法创建图片上下文')
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.scale(scale, scale)
|
||||||
|
drawBackground(ctx, REPORT_WIDTH, reportHeight)
|
||||||
|
|
||||||
|
let cursorY = 82
|
||||||
|
drawTopMark(ctx, cursorY)
|
||||||
|
cursorY += 34
|
||||||
|
|
||||||
|
ctx.fillStyle = '#d6bb84'
|
||||||
|
ctx.font = '500 22px "Noto Serif SC"'
|
||||||
|
ctx.fillText('四课小六壬 · AI 解卦摘要', REPORT_PADDING, cursorY)
|
||||||
|
|
||||||
|
cursorY += 58
|
||||||
|
ctx.fillStyle = '#f5ead8'
|
||||||
|
ctx.font = '700 68px "ZCOOL XiaoWei"'
|
||||||
|
ctx.fillText(input.result.finalPalace.palace, REPORT_PADDING, cursorY)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#f3e5c9'
|
||||||
|
ctx.font = '600 30px "Noto Serif SC"'
|
||||||
|
ctx.fillText(input.result.localInterpretation.verdict, REPORT_PADDING + 196, cursorY - 6)
|
||||||
|
|
||||||
|
cursorY += 58
|
||||||
|
drawTextLines(ctx, questionLines, REPORT_PADDING, cursorY, 42, '#c6b8a0', '500 30px "Noto Serif SC"')
|
||||||
|
cursorY += questionLines.length * 42 + 28
|
||||||
|
|
||||||
|
cursorY = drawMetaBand(ctx, input.result, cursorY, contentWidth)
|
||||||
|
cursorY += REPORT_GAP
|
||||||
|
|
||||||
|
cursorY = drawPalaceStrip(ctx, input.result, cursorY, contentWidth)
|
||||||
|
cursorY += REPORT_GAP
|
||||||
|
|
||||||
|
cursorY = drawRelationPanel(ctx, relationLines, cursorY, contentWidth)
|
||||||
|
cursorY += REPORT_GAP
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
cursorY = drawSection(ctx, section, cursorY, contentWidth)
|
||||||
|
cursorY += REPORT_GAP
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#9c8d72'
|
||||||
|
ctx.font = '400 20px "Noto Serif SC"'
|
||||||
|
ctx.fillText('仅供民俗文化参考,不替代现实决策。', REPORT_PADDING, reportHeight - 44)
|
||||||
|
|
||||||
|
await downloadCanvas(canvas, buildFileName(input.result))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSections(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
input: ExportReportInput,
|
||||||
|
contentWidth: number,
|
||||||
|
): WrappedSection[] {
|
||||||
|
const sectionWidth = contentWidth - 52
|
||||||
|
const aiLines = sanitizeAiLines(input.aiInterpretation)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '总断',
|
||||||
|
lines: wrapContentLines(ctx, [input.result.localInterpretation.summary], sectionWidth, SECTION_TEXT_SIZE, '500'),
|
||||||
|
accent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '趋吉次第',
|
||||||
|
lines: wrapContentLines(ctx, input.result.localInterpretation.actionAdvice, sectionWidth, SECTION_TEXT_SIZE),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '避忌要点',
|
||||||
|
lines: wrapContentLines(ctx, input.result.localInterpretation.riskAdvice, sectionWidth, SECTION_TEXT_SIZE),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI 解卦摘录',
|
||||||
|
lines: wrapContentLines(
|
||||||
|
ctx,
|
||||||
|
aiLines.length > 0 ? aiLines : ['尚未生成 AI 解卦内容。'],
|
||||||
|
sectionWidth,
|
||||||
|
SECTION_TEXT_SIZE,
|
||||||
|
).slice(0, 24),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function measurePosterHeight(input: {
|
||||||
|
questionLines: string[]
|
||||||
|
relationLines: string[]
|
||||||
|
sections: WrappedSection[]
|
||||||
|
}): number {
|
||||||
|
let total = 82
|
||||||
|
total += 34
|
||||||
|
total += 58
|
||||||
|
total += 58
|
||||||
|
total += input.questionLines.length * 42 + 28
|
||||||
|
total += 176
|
||||||
|
total += REPORT_GAP
|
||||||
|
total += 184
|
||||||
|
total += REPORT_GAP
|
||||||
|
total += Math.max(126, 82 + input.relationLines.length * 30)
|
||||||
|
total += REPORT_GAP
|
||||||
|
|
||||||
|
for (const section of input.sections) {
|
||||||
|
total += Math.max(150, 88 + section.lines.length * SECTION_LINE_HEIGHT)
|
||||||
|
total += REPORT_GAP
|
||||||
|
}
|
||||||
|
|
||||||
|
total += 70
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAiLines(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) =>
|
||||||
|
line
|
||||||
|
.replace(/^#{1,6}\s+/, '')
|
||||||
|
.replace(/^(\d+\.)\s*/, '')
|
||||||
|
.replace(/^[-•]\s*/, '')
|
||||||
|
.replace(/^[一二三四五六七八九十]+[、.]\s*/, '')
|
||||||
|
.replace(/\*\*/g, '')
|
||||||
|
.replace(/[【】]/g, ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapContentLines(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
lines: string[],
|
||||||
|
maxWidth: number,
|
||||||
|
fontSize: number,
|
||||||
|
fontWeight = '400',
|
||||||
|
): string[] {
|
||||||
|
return lines.flatMap((line) => wrapLines(ctx, line, maxWidth, fontSize, fontWeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapLines(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
text: string,
|
||||||
|
maxWidth: number,
|
||||||
|
fontSize: number,
|
||||||
|
fontWeight = '400',
|
||||||
|
): string[] {
|
||||||
|
ctx.font = `${fontWeight} ${fontSize}px "Noto Serif SC"`
|
||||||
|
const lines: string[] = []
|
||||||
|
let current = ''
|
||||||
|
|
||||||
|
for (const char of text) {
|
||||||
|
const next = current + char
|
||||||
|
if (ctx.measureText(next).width > maxWidth && current) {
|
||||||
|
lines.push(current)
|
||||||
|
current = char
|
||||||
|
} else {
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
lines.push(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.length > 0 ? lines : ['']
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBackground(ctx: CanvasRenderingContext2D, width: number, height: number) {
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, height)
|
||||||
|
gradient.addColorStop(0, '#090c12')
|
||||||
|
gradient.addColorStop(0.5, '#0a0d14')
|
||||||
|
gradient.addColorStop(1, '#090b12')
|
||||||
|
ctx.fillStyle = gradient
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(147, 22, 15, 0.08)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(width * 0.2, 180, 220, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(214, 187, 132, 0.06)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(width * 0.82, 240, 180, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(214, 187, 132, 0.07)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let x = 0; x <= width; x += 118) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, 0)
|
||||||
|
ctx.lineTo(x, height)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTopMark(ctx: CanvasRenderingContext2D, y: number) {
|
||||||
|
ctx.strokeStyle = 'rgba(214, 187, 132, 0.5)'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(REPORT_PADDING, y)
|
||||||
|
ctx.lineTo(REPORT_PADDING + 132, y)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(REPORT_WIDTH - REPORT_PADDING - 132, y)
|
||||||
|
ctx.lineTo(REPORT_WIDTH - REPORT_PADDING, y)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
ctx.fillStyle = '#d6bb84'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(REPORT_WIDTH / 2, y, 5, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMetaBand(ctx: CanvasRenderingContext2D, result: DivinationResult, y: number, width: number): number {
|
||||||
|
const height = 176
|
||||||
|
drawPanel(ctx, REPORT_PADDING, y, width, height, true)
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
`起课:${result.modeLabel}`,
|
||||||
|
`公历:${result.solarLabel}`,
|
||||||
|
`农历:${result.lunarLabel}`,
|
||||||
|
`终宫:${result.finalPalace.palace} · 五行${result.finalPalace.element}`,
|
||||||
|
`评分:${result.localInterpretation.score}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns = 2
|
||||||
|
const itemWidth = (width - 44) / columns
|
||||||
|
ctx.font = '500 23px "Noto Serif SC"'
|
||||||
|
ctx.fillStyle = '#e4d4b4'
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const col = index % columns
|
||||||
|
const row = Math.floor(index / columns)
|
||||||
|
const itemX = REPORT_PADDING + 24 + col * itemWidth
|
||||||
|
const itemY = y + 44 + row * 42
|
||||||
|
ctx.fillText(item, itemX, itemY)
|
||||||
|
})
|
||||||
|
|
||||||
|
return y + height
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPalaceStrip(ctx: CanvasRenderingContext2D, result: DivinationResult, y: number, width: number): number {
|
||||||
|
const height = 184
|
||||||
|
drawPanel(ctx, REPORT_PADDING, y, width, height)
|
||||||
|
|
||||||
|
ctx.font = '600 28px "Noto Serif SC"'
|
||||||
|
ctx.fillStyle = '#e9d8b6'
|
||||||
|
ctx.fillText('四宫次第', REPORT_PADDING + 24, y + 42)
|
||||||
|
|
||||||
|
const cardY = y + 64
|
||||||
|
const cardWidth = (width - 24 - 18 * 3) / 4
|
||||||
|
|
||||||
|
result.palaces.forEach((item, index) => {
|
||||||
|
const cardX = REPORT_PADDING + 24 + index * (cardWidth + 18)
|
||||||
|
const isFinal = index === result.palaces.length - 1
|
||||||
|
drawRoundedRect(
|
||||||
|
ctx,
|
||||||
|
cardX,
|
||||||
|
cardY,
|
||||||
|
cardWidth,
|
||||||
|
94,
|
||||||
|
18,
|
||||||
|
isFinal ? 'rgba(129, 24, 17, 0.44)' : 'rgba(13, 17, 24, 0.72)',
|
||||||
|
isFinal ? 'rgba(214, 187, 132, 0.44)' : 'rgba(214, 187, 132, 0.16)',
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#d6bb84'
|
||||||
|
ctx.font = '500 18px "Noto Serif SC"'
|
||||||
|
ctx.fillText(item.label, cardX + 18, cardY + 28)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#f5ead8'
|
||||||
|
ctx.font = '700 34px "ZCOOL XiaoWei"'
|
||||||
|
ctx.fillText(item.palace, cardX + 18, cardY + 64)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#b8aa90'
|
||||||
|
ctx.font = '400 18px "Noto Serif SC"'
|
||||||
|
ctx.fillText(`${item.branch} · ${item.element}`, cardX + 18, cardY + 84)
|
||||||
|
})
|
||||||
|
|
||||||
|
return y + height
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRelationPanel(ctx: CanvasRenderingContext2D, lines: string[], y: number, width: number): number {
|
||||||
|
const height = Math.max(126, 82 + lines.length * 30)
|
||||||
|
drawPanel(ctx, REPORT_PADDING, y, width, height)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#d6bb84'
|
||||||
|
ctx.font = '600 28px "Noto Serif SC"'
|
||||||
|
ctx.fillText('生克链路', REPORT_PADDING + 24, y + 42)
|
||||||
|
|
||||||
|
drawTextLines(
|
||||||
|
ctx,
|
||||||
|
lines,
|
||||||
|
REPORT_PADDING + 24,
|
||||||
|
y + 76,
|
||||||
|
30,
|
||||||
|
'#d5ccb8',
|
||||||
|
'400 22px "Noto Serif SC"',
|
||||||
|
)
|
||||||
|
|
||||||
|
return y + height
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSection(ctx: CanvasRenderingContext2D, section: WrappedSection, y: number, width: number): number {
|
||||||
|
const height = Math.max(150, 88 + section.lines.length * SECTION_LINE_HEIGHT)
|
||||||
|
drawPanel(ctx, REPORT_PADDING, y, width, height, section.accent)
|
||||||
|
|
||||||
|
ctx.fillStyle = section.accent ? '#f0dfbc' : '#e2d1af'
|
||||||
|
ctx.font = '600 30px "Noto Serif SC"'
|
||||||
|
ctx.fillText(section.title, REPORT_PADDING + 24, y + 44)
|
||||||
|
|
||||||
|
drawTextLines(
|
||||||
|
ctx,
|
||||||
|
section.lines,
|
||||||
|
REPORT_PADDING + 24,
|
||||||
|
y + 84,
|
||||||
|
SECTION_LINE_HEIGHT,
|
||||||
|
'#d5ccb8',
|
||||||
|
`${section.accent ? '500' : '400'} ${SECTION_TEXT_SIZE}px "Noto Serif SC"`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return y + height
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPanel(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
accent = false,
|
||||||
|
) {
|
||||||
|
drawRoundedRect(
|
||||||
|
ctx,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
26,
|
||||||
|
accent
|
||||||
|
? 'rgba(15, 19, 27, 0.82)'
|
||||||
|
: 'rgba(11, 15, 22, 0.76)',
|
||||||
|
accent
|
||||||
|
? 'rgba(214, 187, 132, 0.26)'
|
||||||
|
: 'rgba(214, 187, 132, 0.16)',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (accent) {
|
||||||
|
const gradient = ctx.createLinearGradient(x, y, x + width, y + height)
|
||||||
|
gradient.addColorStop(0, 'rgba(146, 21, 14, 0.12)')
|
||||||
|
gradient.addColorStop(1, 'rgba(146, 21, 14, 0)')
|
||||||
|
ctx.fillStyle = gradient
|
||||||
|
drawRoundedRect(ctx, x, y, width, height, 26, gradient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRoundedRect(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
radius: number,
|
||||||
|
fill: string | CanvasGradient,
|
||||||
|
stroke?: string,
|
||||||
|
) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x + radius, y)
|
||||||
|
ctx.lineTo(x + width - radius, y)
|
||||||
|
ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
|
||||||
|
ctx.lineTo(x + width, y + height - radius)
|
||||||
|
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
|
||||||
|
ctx.lineTo(x + radius, y + height)
|
||||||
|
ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
|
||||||
|
ctx.lineTo(x, y + radius)
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y)
|
||||||
|
ctx.closePath()
|
||||||
|
|
||||||
|
ctx.fillStyle = fill
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
if (stroke) {
|
||||||
|
ctx.strokeStyle = stroke
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTextLines(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
lines: string[],
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
lineHeight: number,
|
||||||
|
color: string,
|
||||||
|
font: string,
|
||||||
|
) {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = font
|
||||||
|
let cursorY = y
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
ctx.fillText(line, x, cursorY)
|
||||||
|
cursorY += lineHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCanvas(canvas: HTMLCanvasElement, filename: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
reject(new Error('生成图片失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = filename
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
resolve()
|
||||||
|
}, 'image/png')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFileName(result: DivinationResult): string {
|
||||||
|
const timestamp = result.datetime.replace(/[:T-]/g, '').slice(0, 12)
|
||||||
|
return `xiao-liu-ren-report-${timestamp}.png`
|
||||||
|
}
|
||||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
27
web/tsconfig.app.json
Normal file
27
web/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json"},
|
||||||
|
{ "path": "./tsconfig.node.json"}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
15
web/vite.config.ts
Normal file
15
web/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user