feat: add fullstack deployment and oracle app

This commit is contained in:
2026-04-28 13:35:53 +08:00
commit 57fbcf16d4
42 changed files with 7566 additions and 0 deletions

11
.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
.env
.git
bin
*.log

8
server/.env.example Normal file
View 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
View File

@@ -0,0 +1 @@
.env

23
server/Dockerfile Normal file
View 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"]

View 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
View 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
View 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=

View 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
}

View 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",
})
}

View 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"`
}

View 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
View File

@@ -0,0 +1,6 @@
.env
.git
node_modules
dist
.vite
*.log

1
web/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8181

24
web/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

31
web/package.json Normal file
View 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
View File

@@ -0,0 +1 @@
window.__XLY_CONFIG__ = window.__XLY_CONFIG__ || {};

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
View 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

File diff suppressed because it is too large Load Diff

784
web/src/App.tsx Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
web/src/assets/react.svg Normal file
View 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

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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json"},
{ "path": "./tsconfig.node.json"}
]
}

26
web/tsconfig.node.json Normal file
View 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
View 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,
},
},
},
})