diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..21a90f8 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,70 @@ +name: Build and Push Docker Image + +on: + push: + branches: [main] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Extract version from package.json + id: version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Detected package version: $VERSION" + + - name: Lowercase image name + id: image + run: | + IMAGE_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + echo "image_name=$IMAGE_NAME" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU (for multi-arch builds) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ steps.image.outputs.image_name }} + tags: | + type=raw,value=latest + type=raw,value=${{ steps.version.outputs.version }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: production + platforms: | + linux/amd64 + linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 9d2dfb1..46df692 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,83 @@ -# 多阶段构建 Dockerfile -FROM node:18-alpine AS base -WORKDIR /app -# 复制package文件 -COPY package*.json pnpm-lock.yaml ./ -# 安装pnpm -RUN npm install -g pnpm - -# 开发环境 -FROM base AS development -RUN pnpm install -COPY . . -EXPOSE 3000 -CMD ["pnpm", "dev", "--host", "0.0.0.0"] - -# 构建阶段 -FROM base AS builder -RUN pnpm install -COPY . . -RUN pnpm build - -# 生产环境 -FROM nginx:alpine AS production -# 复制自定义nginx配置 -COPY nginx.conf /etc/nginx/nginx.conf -# 复制构建产物 -COPY --from=builder /app/dist /usr/share/nginx/html -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +# 多阶段构建 Dockerfile +FROM node:18-alpine AS base +WORKDIR /app + +# 安装 pnpm +RUN npm install -g pnpm + +# ============================================ +# 前端构建阶段 +# ============================================ +FROM base AS frontend-builder +COPY package*.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm build + +# ============================================ +# 后端构建阶段 +# ============================================ +FROM base AS backend-builder +WORKDIR /app/server +COPY server/package*.json ./ +RUN pnpm install --frozen-lockfile --prod + +# ============================================ +# 生产环境 +# ============================================ +FROM node:18-alpine AS production +WORKDIR /app + +# 安装 tini 用于进程管理 +RUN apk add --no-cache tini + +# 复制后端依赖 +COPY --from=backend-builder /app/server/node_modules ./server/node_modules + +# 复制后端代码 +COPY server/package.json server/index.js ./server/ +COPY server/config ./server/config +COPY server/routes ./server/routes +COPY server/middleware ./server/middleware + +# 复制前端构建产物 +COPY --from=frontend-builder /app/dist ./dist + +# 创建数据目录 +RUN mkdir -p /app/data && chown -R node:node /app/data + +# 设置环境变量 +ENV NODE_ENV=production +ENV PORT=3000 +ENV DATA_DIR=/app/data + +# 暴露端口 +EXPOSE 3000 + +# 使用 tini 作为 PID 1 +ENTRYPOINT ["/sbin/tini", "--"] + +# 启动应用 +CMD ["node", "server/index.js"] + +# ============================================ +# 开发环境 +# ============================================ +FROM base AS development +WORKDIR /app + +# 复制所有 package 文件 +COPY package*.json pnpm-lock.yaml ./ +COPY server/package*.json ./server/ + +# 安装所有依赖 +RUN pnpm install + +# 复制源代码 +COPY . . + +# 暴露端口 +EXPOSE 3000 5173 + +# 启动开发服务器(前端) +CMD ["pnpm", "dev", "--host", "0.0.0.0"] diff --git a/docker-compose.yml b/docker-compose.yml index 6eb6b16..79207e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,41 +1,27 @@ -services: - # 开发环境 - app-dev: - build: - context: . - target: development - ports: - - "3000:3000" - volumes: - - .:/app - - /app/node_modules - environment: - - NODE_ENV=development - profiles: - - dev - - # 生产环境 - app-prod: - build: - context: . - target: production - ports: - - "80:80" - environment: - - NODE_ENV=production - restart: unless-stopped - profiles: - - prod - - # 使用外部nginx(可选) - nginx: - image: nginx:alpine - ports: - - "8080:80" - volumes: - - ./dist:/usr/share/nginx/html:ro - - ./nginx.conf:/etc/nginx/nginx.conf:ro - depends_on: - - app-prod - profiles: - - external-nginx \ No newline at end of file +version: '3.8' + +services: + mo-zhang: + build: + context: . + dockerfile: Dockerfile + target: production + container_name: mo-zhang + ports: + - "3000:3000" + volumes: + # 持久化数据库到本地 + - ./data:/app/data + environment: + - NODE_ENV=production + - PORT=3000 + - DATA_DIR=/app/data + - JWT_SECRET=${JWT_SECRET:-your-secret-key-change-in-production} + - TZ=Asia/Shanghai + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/index.html b/index.html index 53e6b5b..8c4fe16 100644 --- a/index.html +++ b/index.html @@ -2,12 +2,16 @@ - + - 91写作 - AI智能小说创作工具 + + 墨章 · AI 智能小说创作工具 + + +
- \ No newline at end of file + diff --git a/package-lock.json b/package-lock.json index d9e5249..79c2293 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-novel-generator", - "version": "1.0.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-novel-generator", - "version": "1.0.0", + "version": "0.7.0", "dependencies": { "@element-plus/icons-vue": "^2.1.0", "@vueuse/core": "^10.5.0", @@ -16,6 +16,8 @@ "element-plus": "^2.4.2", "file-saver": "^2.0.5", "highlight.js": "^11.9.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "marked": "^9.1.6", "pinia": "^2.1.7", "vue": "^3.3.8", @@ -72,9 +74,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -688,6 +691,26 @@ "@types/lodash": "*" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/web-bluetooth": { "version": "0.0.20", "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", @@ -1173,6 +1196,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1234,6 +1266,26 @@ "node": ">=6" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -1332,6 +1384,18 @@ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "dev": true }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1346,6 +1410,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", @@ -1431,6 +1504,16 @@ "ssr-window": "^3.0.0-alpha.1" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1951,6 +2034,17 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", @@ -1960,6 +2054,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2249,6 +2349,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/i18next": { "version": "20.6.1", "resolved": "https://registry.npmmirror.com/i18next/-/i18next-20.6.1.tgz", @@ -2317,6 +2430,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2443,6 +2562,23 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", @@ -2796,6 +2932,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", @@ -2847,6 +2999,13 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -3039,6 +3198,16 @@ } ] }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", @@ -3063,6 +3232,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz", @@ -3102,6 +3278,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", @@ -3246,6 +3432,16 @@ "resolved": "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz", "integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3306,6 +3502,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/synckit": { "version": "0.11.8", "resolved": "https://registry.npmmirror.com/synckit/-/synckit-0.11.8.tgz", @@ -3321,6 +3527,15 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", @@ -3615,6 +3830,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "4.5.14", "resolved": "https://registry.npmmirror.com/vite/-/vite-4.5.14.tgz", diff --git a/package.json b/package.json index 90355ba..29757cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-novel-generator", - "version": "0.7.0", + "version": "1.7.0", "description": "AI小说写作工具", "type": "module", "scripts": { @@ -18,6 +18,8 @@ "element-plus": "^2.4.2", "file-saver": "^2.0.5", "highlight.js": "^11.9.0", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "marked": "^9.1.6", "pinia": "^2.1.7", "vue": "^3.3.8", @@ -33,4 +35,4 @@ "unplugin-vue-components": "^0.25.2", "vite": "^4.5.0" } -} \ No newline at end of file +} diff --git a/server/config/database.js b/server/config/database.js new file mode 100644 index 0000000..7deeb3e --- /dev/null +++ b/server/config/database.js @@ -0,0 +1,163 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +// 数据目录 +const dataDir = process.env.DATA_DIR || path.join(__dirname, '../../data'); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +// 初始化数据库 +const dbPath = path.join(dataDir, 'mozhang.db'); +const db = new Database(dbPath); + +// 启用 WAL 模式提升性能 +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// 创建表结构 +function initDatabase() { + // 用户表 + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // 小说表 + db.exec(` + CREATE TABLE IF NOT EXISTS novels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + genre TEXT, + tags TEXT, + status TEXT DEFAULT 'draft', + cover_url TEXT, + word_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + // 章节表 + db.exec(` + CREATE TABLE IF NOT EXISTS chapters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + novel_id INTEGER NOT NULL, + title TEXT NOT NULL, + content TEXT, + word_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'draft', + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (novel_id) REFERENCES novels(id) ON DELETE CASCADE + ) + `); + + // 角色表 + db.exec(` + CREATE TABLE IF NOT EXISTS characters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + novel_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT, + role TEXT, + attributes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (novel_id) REFERENCES novels(id) ON DELETE CASCADE + ) + `); + + // 世界观表 + db.exec(` + CREATE TABLE IF NOT EXISTS world_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + novel_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT, + type TEXT, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (novel_id) REFERENCES novels(id) ON DELETE CASCADE + ) + `); + + // 写作目标表 + db.exec(` + CREATE TABLE IF NOT EXISTS writing_goals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + daily_target INTEGER DEFAULT 1000, + weekly_target INTEGER DEFAULT 7000, + monthly_target INTEGER DEFAULT 30000, + streak_days INTEGER DEFAULT 0, + last_write_date DATE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + // API 配置表 + db.exec(` + CREATE TABLE IF NOT EXISTS api_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE, + provider TEXT NOT NULL, + api_key TEXT NOT NULL, + base_url TEXT, + model TEXT, + max_tokens INTEGER DEFAULT 2000, + temperature REAL DEFAULT 0.7, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + // 提示词模板表 + db.exec(` + CREATE TABLE IF NOT EXISTS prompt_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + name TEXT NOT NULL, + category TEXT NOT NULL, + content TEXT NOT NULL, + variables TEXT, + is_system INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + ) + `); + + // 创建索引 + db.exec(` + CREATE INDEX IF NOT EXISTS idx_novels_user ON novels(user_id); + CREATE INDEX IF NOT EXISTS idx_chapters_novel ON chapters(novel_id); + CREATE INDEX IF NOT EXISTS idx_characters_novel ON characters(novel_id); + CREATE INDEX IF NOT EXISTS idx_world_settings_novel ON world_settings(novel_id); + CREATE INDEX IF NOT EXISTS idx_writing_goals_user ON writing_goals(user_id); + CREATE INDEX IF NOT EXISTS idx_api_configs_user ON api_configs(user_id); + CREATE INDEX IF NOT EXISTS idx_prompt_templates_user ON prompt_templates(user_id); + `); + + console.log('Database initialized successfully'); +} + +// 初始化数据库 +initDatabase(); + +module.exports = db; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..d8bdc2b --- /dev/null +++ b/server/index.js @@ -0,0 +1,79 @@ +const express = require('express'); +const cors = require('cors'); +const path = require('path'); +const fs = require('fs'); + +// 导入路由 +const authRoutes = require('./routes/auth'); +const novelRoutes = require('./routes/novels'); +const chapterRoutes = require('./routes/chapters'); +const characterRoutes = require('./routes/characters'); +const worldSettingsRoutes = require('./routes/worldSettings'); +const goalsRoutes = require('./routes/goals'); +const promptsRoutes = require('./routes/prompts'); +const aiRoutes = require('./routes/ai'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// 确保数据目录存在 +const dataDir = process.env.DATA_DIR || path.join(__dirname, '../data'); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +// 中间件 +app.use(cors()); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// 请求日志 +app.use((req, res, next) => { + console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); + next(); +}); + +// API 路由 +app.use('/api/auth', authRoutes); +app.use('/api/novels', novelRoutes); +app.use('/api/chapters', chapterRoutes); +app.use('/api/characters', characterRoutes); +app.use('/api/world-settings', worldSettingsRoutes); +app.use('/api/goals', goalsRoutes); +app.use('/api/prompts', promptsRoutes); +app.use('/api/ai', aiRoutes); + +// 健康检查 +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// 静态文件服务(前端构建产物) +const frontendDist = path.join(__dirname, '../dist'); +if (fs.existsSync(frontendDist)) { + app.use(express.static(frontendDist)); + + // SPA 路由回退 + app.get('*', (req, res) => { + if (!req.path.startsWith('/api')) { + res.sendFile(path.join(frontendDist, 'index.html')); + } + }); +} + +// 错误处理 +app.use((err, req, res, next) => { + console.error('Error:', err); + res.status(500).json({ + error: '服务器内部错误', + message: process.env.NODE_ENV === 'development' ? err.message : undefined + }); +}); + +// 启动服务器 +app.listen(PORT, '0.0.0.0', () => { + console.log(`🚀 墨章服务器运行在 http://0.0.0.0:${PORT}`); + console.log(`📁 数据目录: ${dataDir}`); +}); + +module.exports = app; diff --git a/server/middleware/auth.js b/server/middleware/auth.js new file mode 100644 index 0000000..6a27264 --- /dev/null +++ b/server/middleware/auth.js @@ -0,0 +1,52 @@ +const jwt = require('jsonwebtoken'); + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; +const JWT_EXPIRES_IN = '7d'; + +// 生成 token +function generateToken(user) { + return jwt.sign( + { + id: user.id, + username: user.username, + email: user.email + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); +} + +// 验证 token +function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + return null; + } +} + +// 认证中间件 +function authMiddleware(req, res, next) { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: '未提供认证令牌' }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + + if (!decoded) { + return res.status(401).json({ error: '认证令牌无效或已过期' }); + } + + req.user = decoded; + next(); +} + +module.exports = { + generateToken, + verifyToken, + authMiddleware, + JWT_SECRET +}; diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..71e7db8 --- /dev/null +++ b/server/package.json @@ -0,0 +1,21 @@ +{ + "name": "mo-zhang-server", + "version": "1.0.0", + "description": "墨章 AI 小说创作工具后端服务", + "main": "index.js", + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "better-sqlite3": "^9.4.3", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "axios": "^1.6.0" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/server/routes/ai.js b/server/routes/ai.js new file mode 100644 index 0000000..96839ac --- /dev/null +++ b/server/routes/ai.js @@ -0,0 +1,251 @@ +const express = require('express'); +const axios = require('axios'); +const db = require('../config/database'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +// 所有路由都需要认证 +router.use(authMiddleware); + +// 获取用户 API 配置 +router.get('/config', (req, res) => { + try { + const config = db.prepare('SELECT * FROM api_configs WHERE user_id = ?') + .get(req.user.id); + + if (!config) { + return res.json(null); + } + + // 不返回完整的 API key + const safeConfig = { + ...config, + api_key: config.api_key.substring(0, 8) + '***' + }; + + res.json(safeConfig); + } catch (error) { + console.error('获取 API 配置失败:', error); + res.status(500).json({ error: '获取 API 配置失败' }); + } +}); + +// 保存 API 配置 +router.post('/config', (req, res) => { + try { + const { provider, api_key, base_url, model, max_tokens, temperature } = req.body; + + if (!provider || !api_key) { + return res.status(400).json({ error: '提供商和 API key 不能为空' }); + } + + const existing = db.prepare('SELECT * FROM api_configs WHERE user_id = ?') + .get(req.user.id); + + if (existing) { + db.prepare(` + UPDATE api_configs + SET provider = ?, api_key = ?, base_url = ?, model = ?, + max_tokens = ?, temperature = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + `).run(provider, api_key, base_url, model, max_tokens || 2000, temperature || 0.7, req.user.id); + } else { + db.prepare(` + INSERT INTO api_configs (user_id, provider, api_key, base_url, model, max_tokens, temperature) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(req.user.id, provider, api_key, base_url, model, max_tokens || 2000, temperature || 0.7); + } + + res.json({ message: 'API 配置保存成功' }); + } catch (error) { + console.error('保存 API 配置失败:', error); + res.status(500).json({ error: '保存 API 配置失败' }); + } +}); + +// AI 聊天完成 +router.post('/chat/completions', async (req, res) => { + try { + const config = db.prepare('SELECT * FROM api_configs WHERE user_id = ?') + .get(req.user.id); + + if (!config) { + return res.status(400).json({ error: '请先配置 API' }); + } + + const { messages, stream = false } = req.body; + + if (!messages || !Array.isArray(messages)) { + return res.status(400).json({ error: 'messages 必须是数组' }); + } + + // 构建请求 + const baseUrl = config.base_url || 'https://api.openai.com/v1'; + const url = `${baseUrl}/chat/completions`; + + const response = await axios.post(url, { + model: config.model || 'gpt-3.5-turbo', + messages, + max_tokens: config.max_tokens || 2000, + temperature: config.temperature || 0.7, + stream + }, { + headers: { + 'Authorization': `Bearer ${config.api_key}`, + 'Content-Type': 'application/json' + }, + timeout: 60000, + responseType: stream ? 'stream' : 'json' + }); + + if (stream) { + // 流式响应 + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + response.data.on('data', (chunk) => { + res.write(chunk); + }); + + response.data.on('end', () => { + res.end(); + }); + } else { + res.json(response.data); + } + } catch (error) { + console.error('AI 请求失败:', error.response?.data || error.message); + res.status(500).json({ + error: 'AI 请求失败', + details: error.response?.data || error.message + }); + } +}); + +// 生成大纲 +router.post('/generate/outline', async (req, res) => { + try { + const config = db.prepare('SELECT * FROM api_configs WHERE user_id = ?') + .get(req.user.id); + + if (!config) { + return res.status(400).json({ error: '请先配置 API' }); + } + + const { keywords, genre, template } = req.body; + + if (!keywords) { + return res.status(400).json({ error: '关键词不能为空' }); + } + + const prompt = `请为以下小说生成详细大纲: +类型:${genre || '通用'} +关键词:${keywords} +${template ? `模板要求:${template}` : ''} + +请生成包含以下内容的完整大纲: +1. 故事背景 +2. 主要人物设定 +3. 核心冲突 +4. 章节结构(至少5章) +5. 故事高潮和结局 + +请用中文回答,格式清晰。`; + + const baseUrl = config.base_url || 'https://api.openai.com/v1'; + const response = await axios.post(`${baseUrl}/chat/completions`, { + model: config.model || 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: '你是一位专业的小说作家,擅长创作各种类型的小说。' }, + { role: 'user', content: prompt } + ], + max_tokens: config.max_tokens || 2000, + temperature: config.temperature || 0.7 + }, { + headers: { + 'Authorization': `Bearer ${config.api_key}`, + 'Content-Type': 'application/json' + }, + timeout: 60000 + }); + + res.json({ + outline: response.data.choices[0].message.content + }); + } catch (error) { + console.error('生成大纲失败:', error.response?.data || error.message); + res.status(500).json({ + error: '生成大纲失败', + details: error.response?.data || error.message + }); + } +}); + +// 生成章节内容 +router.post('/generate/chapter', async (req, res) => { + try { + const config = db.prepare('SELECT * FROM api_configs WHERE user_id = ?') + .get(req.user.id); + + if (!config) { + return res.status(400).json({ error: '请先配置 API' }); + } + + const { novelId, chapterTitle, outline, previousContent, direction, wordCount } = req.body; + + if (!chapterTitle) { + return res.status(400).json({ error: '章节标题不能为空' }); + } + + let prompt = `请为小说章节"${chapterTitle}"生成内容。`; + + if (outline) { + prompt += `\n\n大纲:${outline}`; + } + + if (previousContent) { + prompt += `\n\n前文内容:${previousContent.substring(0, 1000)}...`; + } + + if (direction) { + prompt += `\n\n创作方向:${direction}`; + } + + prompt += `\n\n请生成约${wordCount || 2000}字的章节内容,要求: +1. 情节连贯,逻辑清晰 +2. 人物形象鲜明 +3. 文笔流畅,富有感染力 +4. 符合小说整体风格`; + + const baseUrl = config.base_url || 'https://api.openai.com/v1'; + const response = await axios.post(`${baseUrl}/chat/completions`, { + model: config.model || 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: '你是一位专业的小说作家,擅长创作各种类型的小说。' }, + { role: 'user', content: prompt } + ], + max_tokens: config.max_tokens || 2000, + temperature: config.temperature || 0.7 + }, { + headers: { + 'Authorization': `Bearer ${config.api_key}`, + 'Content-Type': 'application/json' + }, + timeout: 60000 + }); + + res.json({ + content: response.data.choices[0].message.content + }); + } catch (error) { + console.error('生成章节失败:', error.response?.data || error.message); + res.status(500).json({ + error: '生成章节失败', + details: error.response?.data || error.message + }); + } +}); + +module.exports = router; diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..f8b33c1 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,142 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const db = require('../config/database'); +const { generateToken, authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +// 注册 +router.post('/register', async (req, res) => { + try { + const { username, email, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: '用户名和密码不能为空' }); + } + + // 检查用户是否已存在 + const existingUser = db.prepare('SELECT id FROM users WHERE username = ? OR email = ?').get(username, email); + if (existingUser) { + return res.status(400).json({ error: '用户名或邮箱已存在' }); + } + + // 加密密码 + const passwordHash = await bcrypt.hash(password, 10); + + // 创建用户 + const result = db.prepare( + 'INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)' + ).run(username, email, passwordHash); + + // 创建默认写作目标 + db.prepare( + 'INSERT INTO writing_goals (user_id) VALUES (?)' + ).run(result.lastInsertRowid); + + const user = { + id: result.lastInsertRowid, + username, + email + }; + + const token = generateToken(user); + + res.status(201).json({ + message: '注册成功', + token, + user: { + id: user.id, + username: user.username, + email: user.email + } + }); + } catch (error) { + console.error('注册失败:', error); + res.status(500).json({ error: '注册失败' }); + } +}); + +// 登录 +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: '用户名和密码不能为空' }); + } + + // 查找用户 + const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username); + if (!user) { + return res.status(401).json({ error: '用户名或密码错误' }); + } + + // 验证密码 + const isValid = await bcrypt.compare(password, user.password_hash); + if (!isValid) { + return res.status(401).json({ error: '用户名或密码错误' }); + } + + const token = generateToken({ + id: user.id, + username: user.username, + email: user.email + }); + + res.json({ + message: '登录成功', + token, + user: { + id: user.id, + username: user.username, + email: user.email + } + }); + } catch (error) { + console.error('登录失败:', error); + res.status(500).json({ error: '登录失败' }); + } +}); + +// 获取当前用户信息 +router.get('/me', authMiddleware, (req, res) => { + try { + const user = db.prepare('SELECT id, username, email, created_at FROM users WHERE id = ?').get(req.user.id); + if (!user) { + return res.status(404).json({ error: '用户不存在' }); + } + res.json(user); + } catch (error) { + console.error('获取用户信息失败:', error); + res.status(500).json({ error: '获取用户信息失败' }); + } +}); + +// 修改密码 +router.put('/password', authMiddleware, async (req, res) => { + try { + const { oldPassword, newPassword } = req.body; + + if (!oldPassword || !newPassword) { + return res.status(400).json({ error: '旧密码和新密码不能为空' }); + } + + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); + const isValid = await bcrypt.compare(oldPassword, user.password_hash); + + if (!isValid) { + return res.status(400).json({ error: '旧密码错误' }); + } + + const passwordHash = await bcrypt.hash(newPassword, 10); + db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .run(passwordHash, req.user.id); + + res.json({ message: '密码修改成功' }); + } catch (error) { + console.error('修改密码失败:', error); + res.status(500).json({ error: '修改密码失败' }); + } +}); + +module.exports = router; diff --git a/server/routes/chapters.js b/server/routes/chapters.js new file mode 100644 index 0000000..e620c21 --- /dev/null +++ b/server/routes/chapters.js @@ -0,0 +1,212 @@ +const express = require('express'); +const db = require('../config/database'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +// 所有路由都需要认证 +router.use(authMiddleware); + +// 获取小说的所有章节 +router.get('/novel/:novelId', (req, res) => { + try { + const novel = db.prepare('SELECT * FROM novels WHERE id = ? AND user_id = ?') + .get(req.params.novelId, req.user.id); + + if (!novel) { + return res.status(404).json({ error: '小说不存在' }); + } + + const chapters = db.prepare(` + SELECT * FROM chapters + WHERE novel_id = ? + ORDER BY sort_order ASC, created_at ASC + `).all(req.params.novelId); + + res.json(chapters); + } catch (error) { + console.error('获取章节列表失败:', error); + res.status(500).json({ error: '获取章节列表失败' }); + } +}); + +// 获取单个章节 +router.get('/:id', (req, res) => { + try { + const chapter = db.prepare(` + SELECT c.* FROM chapters c + JOIN novels n ON c.novel_id = n.id + WHERE c.id = ? AND n.user_id = ? + `).get(req.params.id, req.user.id); + + if (!chapter) { + return res.status(404).json({ error: '章节不存在' }); + } + + res.json(chapter); + } catch (error) { + console.error('获取章节详情失败:', error); + res.status(500).json({ error: '获取章节详情失败' }); + } +}); + +// 创建章节 +router.post('/novel/:novelId', (req, res) => { + try { + const { title, content } = req.body; + + if (!title) { + return res.status(400).json({ error: '章节标题不能为空' }); + } + + const novel = db.prepare('SELECT * FROM novels WHERE id = ? AND user_id = ?') + .get(req.params.novelId, req.user.id); + + if (!novel) { + return res.status(404).json({ error: '小说不存在' }); + } + + // 获取当前最大排序值 + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM chapters WHERE novel_id = ?') + .get(req.params.novelId); + const sortOrder = (maxOrder.max || 0) + 1; + + const wordCount = content ? content.length : 0; + + const result = db.prepare(` + INSERT INTO chapters (novel_id, title, content, word_count, sort_order) + VALUES (?, ?, ?, ?, ?) + `).run(req.params.novelId, title, content || '', wordCount, sortOrder); + + // 更新小说字数 + db.prepare(` + UPDATE novels + SET word_count = (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE novel_id = ?), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(req.params.novelId, req.params.novelId); + + const chapter = db.prepare('SELECT * FROM chapters WHERE id = ?').get(result.lastInsertRowid); + + res.status(201).json({ + message: '章节创建成功', + chapter + }); + } catch (error) { + console.error('创建章节失败:', error); + res.status(500).json({ error: '创建章节失败' }); + } +}); + +// 更新章节 +router.put('/:id', (req, res) => { + try { + const { title, content, status } = req.body; + + const chapter = db.prepare(` + SELECT c.* FROM chapters c + JOIN novels n ON c.novel_id = n.id + WHERE c.id = ? AND n.user_id = ? + `).get(req.params.id, req.user.id); + + if (!chapter) { + return res.status(404).json({ error: '章节不存在' }); + } + + const wordCount = content !== undefined ? content.length : chapter.word_count; + + db.prepare(` + UPDATE chapters + SET title = ?, content = ?, word_count = ?, status = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + title || chapter.title, + content !== undefined ? content : chapter.content, + wordCount, + status || chapter.status, + req.params.id + ); + + // 更新小说字数 + db.prepare(` + UPDATE novels + SET word_count = (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE novel_id = ?), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(chapter.novel_id, chapter.novel_id); + + const updatedChapter = db.prepare('SELECT * FROM chapters WHERE id = ?').get(req.params.id); + + res.json({ + message: '章节更新成功', + chapter: updatedChapter + }); + } catch (error) { + console.error('更新章节失败:', error); + res.status(500).json({ error: '更新章节失败' }); + } +}); + +// 删除章节 +router.delete('/:id', (req, res) => { + try { + const chapter = db.prepare(` + SELECT c.* FROM chapters c + JOIN novels n ON c.novel_id = n.id + WHERE c.id = ? AND n.user_id = ? + `).get(req.params.id, req.user.id); + + if (!chapter) { + return res.status(404).json({ error: '章节不存在' }); + } + + db.prepare('DELETE FROM chapters WHERE id = ?').run(req.params.id); + + // 更新小说字数 + db.prepare(` + UPDATE novels + SET word_count = (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE novel_id = ?), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(chapter.novel_id, chapter.novel_id); + + res.json({ message: '章节删除成功' }); + } catch (error) { + console.error('删除章节失败:', error); + res.status(500).json({ error: '删除章节失败' }); + } +}); + +// 重新排序章节 +router.put('/novel/:novelId/reorder', (req, res) => { + try { + const { chapterIds } = req.body; // 数组,表示新的排序顺序 + + if (!Array.isArray(chapterIds)) { + return res.status(400).json({ error: 'chapterIds 必须是数组' }); + } + + const novel = db.prepare('SELECT * FROM novels WHERE id = ? AND user_id = ?') + .get(req.params.novelId, req.user.id); + + if (!novel) { + return res.status(404).json({ error: '小说不存在' }); + } + + const updateMany = db.transaction((ids) => { + ids.forEach((id, index) => { + db.prepare('UPDATE chapters SET sort_order = ? WHERE id = ? AND novel_id = ?') + .run(index + 1, id, req.params.novelId); + }); + }); + + updateMany(chapterIds); + + res.json({ message: '章节排序更新成功' }); + } catch (error) { + console.error('章节排序失败:', error); + res.status(500).json({ error: '章节排序失败' }); + } +}); + +module.exports = router; diff --git a/server/routes/characters.js b/server/routes/characters.js new file mode 100644 index 0000000..c8b738e --- /dev/null +++ b/server/routes/characters.js @@ -0,0 +1,123 @@ +const express = require('express'); +const db = require('../config/database'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +router.use(authMiddleware); + +// 获取小说的所有角色 +router.get('/novel/:novelId', (req, res) => { + try { + const novel = db.prepare('SELECT * FROM novels WHERE id = ? AND user_id = ?') + .get(req.params.novelId, req.user.id); + + if (!novel) { + return res.status(404).json({ error: '小说不存在' }); + } + + const characters = db.prepare('SELECT * FROM characters WHERE novel_id = ? ORDER BY created_at DESC') + .all(req.params.novelId); + + res.json(characters); + } catch (error) { + console.error('获取角色列表失败:', error); + res.status(500).json({ error: '获取角色列表失败' }); + } +}); + +// 创建角色 +router.post('/novel/:novelId', (req, res) => { + try { + const { name, description, role, attributes } = req.body; + + if (!name) { + return res.status(400).json({ error: '角色名称不能为空' }); + } + + const novel = db.prepare('SELECT * FROM novels WHERE id = ? AND user_id = ?') + .get(req.params.novelId, req.user.id); + + if (!novel) { + return res.status(404).json({ error: '小说不存在' }); + } + + const result = db.prepare(` + INSERT INTO characters (novel_id, name, description, role, attributes) + VALUES (?, ?, ?, ?, ?) + `).run(req.params.novelId, name, description || '', role || '', attributes || '{}'); + + const character = db.prepare('SELECT * FROM characters WHERE id = ?').get(result.lastInsertRowid); + + res.status(201).json({ + message: '角色创建成功', + character + }); + } catch (error) { + console.error('创建角色失败:', error); + res.status(500).json({ error: '创建角色失败' }); + } +}); + +// 更新角色 +router.put('/:id', (req, res) => { + try { + const { name, description, role, attributes } = req.body; + + const character = db.prepare(` + SELECT c.* FROM characters c + JOIN novels n ON c.novel_id = n.id + WHERE c.id = ? AND n.user_id = ? + `).get(req.params.id, req.user.id); + + if (!character) { + return res.status(404).json({ error: '角色不存在' }); + } + + db.prepare(` + UPDATE characters + SET name = ?, description = ?, role = ?, attributes = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + name || character.name, + description !== undefined ? description : character.description, + role !== undefined ? role : character.role, + attributes !== undefined ? attributes : character.attributes, + req.params.id + ); + + const updatedCharacter = db.prepare('SELECT * FROM characters WHERE id = ?').get(req.params.id); + + res.json({ + message: '角色更新成功', + character: updatedCharacter + }); + } catch (error) { + console.error('更新角色失败:', error); + res.status(500).json({ error: '更新角色失败' }); + } +}); + +// 删除角色 +router.delete('/:id', (req, res) => { + try { + const character = db.prepare(` + SELECT c.* FROM characters c + JOIN novels n ON c.novel_id = n.id + WHERE c.id = ? AND n.user_id = ? + `).get(req.params.id, req.user.id); + + if (!character) { + return res.status(404).json({ error: '角色不存在' }); + } + + db.prepare('DELETE FROM characters WHERE id = ?').run(req.params.id); + + res.json({ message: '角色删除成功' }); + } catch (error) { + console.error('删除角色失败:', error); + res.status(500).json({ error: '删除角色失败' }); + } +}); + +module.exports = router; diff --git a/server/routes/goals.js b/server/routes/goals.js new file mode 100644 index 0000000..11fbc51 --- /dev/null +++ b/server/routes/goals.js @@ -0,0 +1,118 @@ +const express = require('express'); +const db = require('../config/database'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +router.use(authMiddleware); + +// 获取写作目标 +router.get('/', (req, res) => { + try { + let goal = db.prepare('SELECT * FROM writing_goals WHERE user_id = ?').get(req.user.id); + + if (!goal) { + // 创建默认目标 + db.prepare('INSERT INTO writing_goals (user_id) VALUES (?)').run(req.user.id); + goal = db.prepare('SELECT * FROM writing_goals WHERE user_id = ?').get(req.user.id); + } + + res.json(goal); + } catch (error) { + console.error('获取写作目标失败:', error); + res.status(500).json({ error: '获取写作目标失败' }); + } +}); + +// 更新写作目标 +router.put('/', (req, res) => { + try { + const { daily_target, weekly_target, monthly_target } = req.body; + + const existing = db.prepare('SELECT * FROM writing_goals WHERE user_id = ?').get(req.user.id); + + if (!existing) { + db.prepare(` + INSERT INTO writing_goals (user_id, daily_target, weekly_target, monthly_target) + VALUES (?, ?, ?, ?) + `).run( + req.user.id, + daily_target || 1000, + weekly_target || 7000, + monthly_target || 30000 + ); + } else { + db.prepare(` + UPDATE writing_goals + SET daily_target = ?, weekly_target = ?, monthly_target = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + `).run( + daily_target !== undefined ? daily_target : existing.daily_target, + weekly_target !== undefined ? weekly_target : existing.weekly_target, + monthly_target !== undefined ? monthly_target : existing.monthly_target, + req.user.id + ); + } + + const goal = db.prepare('SELECT * FROM writing_goals WHERE user_id = ?').get(req.user.id); + + res.json({ + message: '写作目标更新成功', + goal + }); + } catch (error) { + console.error('更新写作目标失败:', error); + res.status(500).json({ error: '更新写作目标失败' }); + } +}); + +// 记录写作活动(更新连续天数) +router.post('/activity', (req, res) => { + try { + const { word_count } = req.body; + const today = new Date().toISOString().split('T')[0]; + + let goal = db.prepare('SELECT * FROM writing_goals WHERE user_id = ?').get(req.user.id); + + if (!goal) { + db.prepare('INSERT INTO writing_goals (user_id) VALUES (?)').run(req.user.id); + goal = db.prepare('SELECT * FROM writing_goals WHERE user_id = ?').get(req.user.id); + } + + let streakDays = goal.streak_days || 0; + const lastWriteDate = goal.last_write_date; + + if (lastWriteDate === today) { + // 今天已经记录过 + return res.json({ message: '今日已记录', goal }); + } + + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; + + if (lastWriteDate === yesterday) { + // 连续写作 + streakDays += 1; + } else { + // 重新开始 + streakDays = 1; + } + + db.prepare(` + UPDATE writing_goals + SET streak_days = ?, last_write_date = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + `).run(streakDays, today, req.user.id); + + const updatedGoal = db.prepare('SELECT * FROM writing_goals WHERE user_id = ?').get(req.user.id); + + res.json({ + message: '写作活动已记录', + goal: updatedGoal + }); + } catch (error) { + console.error('记录写作活动失败:', error); + res.status(500).json({ error: '记录写作活动失败' }); + } +}); + +module.exports = router; diff --git a/server/routes/novels.js b/server/routes/novels.js new file mode 100644 index 0000000..41bd006 --- /dev/null +++ b/server/routes/novels.js @@ -0,0 +1,139 @@ +const express = require('express'); +const db = require('../config/database'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +// 所有路由都需要认证 +router.use(authMiddleware); + +// 获取所有小说 +router.get('/', (req, res) => { + try { + const novels = db.prepare(` + SELECT n.*, + (SELECT COUNT(*) FROM chapters WHERE novel_id = n.id) as chapter_count + FROM novels n + WHERE n.user_id = ? + ORDER BY n.updated_at DESC + `).all(req.user.id); + + res.json(novels); + } catch (error) { + console.error('获取小说列表失败:', error); + res.status(500).json({ error: '获取小说列表失败' }); + } +}); + +// 获取单个小说详情 +router.get('/:id', (req, res) => { + try { + const novel = db.prepare(` + SELECT n.*, + (SELECT COUNT(*) FROM chapters WHERE novel_id = n.id) as chapter_count + FROM novels n + WHERE n.id = ? AND n.user_id = ? + `).get(req.params.id, req.user.id); + + if (!novel) { + return res.status(404).json({ error: '小说不存在' }); + } + + res.json(novel); + } catch (error) { + console.error('获取小说详情失败:', error); + res.status(500).json({ error: '获取小说详情失败' }); + } +}); + +// 创建小说 +router.post('/', (req, res) => { + try { + const { title, description, genre, tags, cover_url } = req.body; + + if (!title) { + return res.status(400).json({ error: '小说标题不能为空' }); + } + + const result = db.prepare(` + INSERT INTO novels (user_id, title, description, genre, tags, cover_url) + VALUES (?, ?, ?, ?, ?, ?) + `).run( + req.user.id, + title, + description || '', + genre || '', + tags || '', + cover_url || '' + ); + + const novel = db.prepare('SELECT * FROM novels WHERE id = ?').get(result.lastInsertRowid); + + res.status(201).json({ + message: '小说创建成功', + novel + }); + } catch (error) { + console.error('创建小说失败:', error); + res.status(500).json({ error: '创建小说失败' }); + } +}); + +// 更新小说 +router.put('/:id', (req, res) => { + try { + const { title, description, genre, tags, status, cover_url } = req.body; + + const novel = db.prepare('SELECT * FROM novels WHERE id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + + if (!novel) { + return res.status(404).json({ error: '小说不存在' }); + } + + db.prepare(` + UPDATE novels + SET title = ?, description = ?, genre = ?, tags = ?, status = ?, cover_url = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + title || novel.title, + description !== undefined ? description : novel.description, + genre !== undefined ? genre : novel.genre, + tags !== undefined ? tags : novel.tags, + status || novel.status, + cover_url !== undefined ? cover_url : novel.cover_url, + req.params.id + ); + + const updatedNovel = db.prepare('SELECT * FROM novels WHERE id = ?').get(req.params.id); + + res.json({ + message: '小说更新成功', + novel: updatedNovel + }); + } catch (error) { + console.error('更新小说失败:', error); + res.status(500).json({ error: '更新小说失败' }); + } +}); + +// 删除小说 +router.delete('/:id', (req, res) => { + try { + const novel = db.prepare('SELECT * FROM novels WHERE id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + + if (!novel) { + return res.status(404).json({ error: '小说不存在' }); + } + + db.prepare('DELETE FROM novels WHERE id = ?').run(req.params.id); + + res.json({ message: '小说删除成功' }); + } catch (error) { + console.error('删除小说失败:', error); + res.status(500).json({ error: '删除小说失败' }); + } +}); + +module.exports = router; diff --git a/server/routes/prompts.js b/server/routes/prompts.js new file mode 100644 index 0000000..d1a2170 --- /dev/null +++ b/server/routes/prompts.js @@ -0,0 +1,150 @@ +const express = require('express'); +const db = require('../config/database'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +router.use(authMiddleware); + +// 获取所有提示词模板 +router.get('/', (req, res) => { + try { + const { category } = req.query; + + let templates; + if (category) { + templates = db.prepare(` + SELECT * FROM prompt_templates + WHERE (user_id = ? OR is_system = 1) AND category = ? + ORDER BY is_system DESC, created_at DESC + `).all(req.user.id, category); + } else { + templates = db.prepare(` + SELECT * FROM prompt_templates + WHERE user_id = ? OR is_system = 1 + ORDER BY is_system DESC, created_at DESC + `).all(req.user.id); + } + + res.json(templates); + } catch (error) { + console.error('获取提示词模板失败:', error); + res.status(500).json({ error: '获取提示词模板失败' }); + } +}); + +// 获取单个提示词模板 +router.get('/:id', (req, res) => { + try { + const template = db.prepare(` + SELECT * FROM prompt_templates + WHERE id = ? AND (user_id = ? OR is_system = 1) + `).get(req.params.id, req.user.id); + + if (!template) { + return res.status(404).json({ error: '提示词模板不存在' }); + } + + res.json(template); + } catch (error) { + console.error('获取提示词模板详情失败:', error); + res.status(500).json({ error: '获取提示词模板详情失败' }); + } +}); + +// 创建提示词模板 +router.post('/', (req, res) => { + try { + const { name, category, content, variables } = req.body; + + if (!name || !category || !content) { + return res.status(400).json({ error: '名称、分类和内容不能为空' }); + } + + const result = db.prepare(` + INSERT INTO prompt_templates (user_id, name, category, content, variables) + VALUES (?, ?, ?, ?, ?) + `).run( + req.user.id, + name, + category, + content, + variables || '[]' + ); + + const template = db.prepare('SELECT * FROM prompt_templates WHERE id = ?').get(result.lastInsertRowid); + + res.status(201).json({ + message: '提示词模板创建成功', + template + }); + } catch (error) { + console.error('创建提示词模板失败:', error); + res.status(500).json({ error: '创建提示词模板失败' }); + } +}); + +// 更新提示词模板 +router.put('/:id', (req, res) => { + try { + const { name, category, content, variables } = req.body; + + const template = db.prepare(` + SELECT * FROM prompt_templates + WHERE id = ? AND user_id = ? + `).get(req.params.id, req.user.id); + + if (!template) { + return res.status(404).json({ error: '提示词模板不存在或无权修改' }); + } + + db.prepare(` + UPDATE prompt_templates + SET name = ?, category = ?, content = ?, variables = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + name || template.name, + category || template.category, + content || template.content, + variables !== undefined ? variables : template.variables, + req.params.id + ); + + const updatedTemplate = db.prepare('SELECT * FROM prompt_templates WHERE id = ?').get(req.params.id); + + res.json({ + message: '提示词模板更新成功', + template: updatedTemplate + }); + } catch (error) { + console.error('更新提示词模板失败:', error); + res.status(500).json({ error: '更新提示词模板失败' }); + } +}); + +// 删除提示词模板 +router.delete('/:id', (req, res) => { + try { + const template = db.prepare(` + SELECT * FROM prompt_templates + WHERE id = ? AND user_id = ? + `).get(req.params.id, req.user.id); + + if (!template) { + return res.status(404).json({ error: '提示词模板不存在或无权删除' }); + } + + if (template.is_system) { + return res.status(403).json({ error: '系统模板不能删除' }); + } + + db.prepare('DELETE FROM prompt_templates WHERE id = ?').run(req.params.id); + + res.json({ message: '提示词模板删除成功' }); + } catch (error) { + console.error('删除提示词模板失败:', error); + res.status(500).json({ error: '删除提示词模板失败' }); + } +}); + +module.exports = router; diff --git a/server/routes/worldSettings.js b/server/routes/worldSettings.js new file mode 100644 index 0000000..be8bc2a --- /dev/null +++ b/server/routes/worldSettings.js @@ -0,0 +1,123 @@ +const express = require('express'); +const db = require('../config/database'); +const { authMiddleware } = require('../middleware/auth'); + +const router = express.Router(); + +router.use(authMiddleware); + +// 获取小说的所有世界观设定 +router.get('/novel/:novelId', (req, res) => { + try { + const novel = db.prepare('SELECT * FROM novels WHERE id = ? AND user_id = ?') + .get(req.params.novelId, req.user.id); + + if (!novel) { + return res.status(404).json({ error: '小说不存在' }); + } + + const settings = db.prepare('SELECT * FROM world_settings WHERE novel_id = ? ORDER BY created_at DESC') + .all(req.params.novelId); + + res.json(settings); + } catch (error) { + console.error('获取世界观设定失败:', error); + res.status(500).json({ error: '获取世界观设定失败' }); + } +}); + +// 创建世界观设定 +router.post('/novel/:novelId', (req, res) => { + try { + const { name, description, type, details } = req.body; + + if (!name) { + return res.status(400).json({ error: '设定名称不能为空' }); + } + + const novel = db.prepare('SELECT * FROM novels WHERE id = ? AND user_id = ?') + .get(req.params.novelId, req.user.id); + + if (!novel) { + return res.status(404).json({ error: '小说不存在' }); + } + + const result = db.prepare(` + INSERT INTO world_settings (novel_id, name, description, type, details) + VALUES (?, ?, ?, ?, ?) + `).run(req.params.novelId, name, description || '', type || '', details || '{}'); + + const setting = db.prepare('SELECT * FROM world_settings WHERE id = ?').get(result.lastInsertRowid); + + res.status(201).json({ + message: '世界观设定创建成功', + setting + }); + } catch (error) { + console.error('创建世界观设定失败:', error); + res.status(500).json({ error: '创建世界观设定失败' }); + } +}); + +// 更新世界观设定 +router.put('/:id', (req, res) => { + try { + const { name, description, type, details } = req.body; + + const setting = db.prepare(` + SELECT ws.* FROM world_settings ws + JOIN novels n ON ws.novel_id = n.id + WHERE ws.id = ? AND n.user_id = ? + `).get(req.params.id, req.user.id); + + if (!setting) { + return res.status(404).json({ error: '世界观设定不存在' }); + } + + db.prepare(` + UPDATE world_settings + SET name = ?, description = ?, type = ?, details = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + name || setting.name, + description !== undefined ? description : setting.description, + type !== undefined ? type : setting.type, + details !== undefined ? details : setting.details, + req.params.id + ); + + const updatedSetting = db.prepare('SELECT * FROM world_settings WHERE id = ?').get(req.params.id); + + res.json({ + message: '世界观设定更新成功', + setting: updatedSetting + }); + } catch (error) { + console.error('更新世界观设定失败:', error); + res.status(500).json({ error: '更新世界观设定失败' }); + } +}); + +// 删除世界观设定 +router.delete('/:id', (req, res) => { + try { + const setting = db.prepare(` + SELECT ws.* FROM world_settings ws + JOIN novels n ON ws.novel_id = n.id + WHERE ws.id = ? AND n.user_id = ? + `).get(req.params.id, req.user.id); + + if (!setting) { + return res.status(404).json({ error: '世界观设定不存在' }); + } + + db.prepare('DELETE FROM world_settings WHERE id = ?').run(req.params.id); + + res.json({ message: '世界观设定删除成功' }); + } catch (error) { + console.error('删除世界观设定失败:', error); + res.status(500).json({ error: '删除世界观设定失败' }); + } +}); + +module.exports = router; diff --git a/src/components/ApiConfig.vue b/src/components/ApiConfig.vue index 8ef4aa4..5e1187d 100644 --- a/src/components/ApiConfig.vue +++ b/src/components/ApiConfig.vue @@ -1,239 +1,97 @@