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