实现小游戏与小程序项目创建发布
This commit is contained in:
@@ -48,7 +48,11 @@ import {
|
||||
upsertProjectSessionMetadata,
|
||||
type ProjectConversationState,
|
||||
} from '../../../shared/project-conversations';
|
||||
import type { ProjectConfig } from '../../../shared/project-config';
|
||||
import {
|
||||
isProjectType,
|
||||
type ProjectConfig,
|
||||
type ProjectType,
|
||||
} from '../../../shared/project-config';
|
||||
import { initializeProjectDirectory } from '../../opencode/project-directory-initialization';
|
||||
|
||||
const bundledCourseSkillIds = new Set<string>(BUNDLED_COURSE_SKILL_IDS);
|
||||
@@ -82,6 +86,13 @@ const COMMAND_IMAGE_DATA_URL_PATTERN =
|
||||
/^data:(image\/[A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/]*={0,2})$/iu;
|
||||
const STRICT_BASE64_PATTERN =
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
|
||||
function parseOptionalProjectType(value: unknown): ProjectType | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isProjectType(value)) {
|
||||
throw new Error('Invalid project type');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
@@ -956,8 +967,10 @@ export async function handleOpencodeRoutes(
|
||||
projectPath?: string;
|
||||
parentPath?: string;
|
||||
projectName?: string;
|
||||
projectType?: unknown;
|
||||
defaultModel?: unknown;
|
||||
}>(req);
|
||||
const projectType = parseOptionalProjectType(body.projectType);
|
||||
const defaultModel = body.defaultModel === undefined || body.defaultModel === null
|
||||
? null
|
||||
: typeof body.defaultModel === 'string' && body.defaultModel.trim()
|
||||
@@ -972,6 +985,7 @@ export async function handleOpencodeRoutes(
|
||||
: buildNewProjectPath(body.parentPath, body.projectName);
|
||||
const { config } = await initializeProjectDirectory({
|
||||
projectPath,
|
||||
projectType,
|
||||
defaultModel,
|
||||
allowExistingDirectory: useSelectedDirectory,
|
||||
});
|
||||
|
||||
@@ -2,11 +2,14 @@ import path from 'node:path';
|
||||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
createProjectConfig,
|
||||
isProjectType,
|
||||
validateAgentConfigs,
|
||||
validateAgentNames,
|
||||
type ProjectAgentConfig,
|
||||
type ProjectConfig,
|
||||
type ProjectType,
|
||||
} from '../../shared/project-config';
|
||||
import { createPublishableProjectTemplate } from './project-template';
|
||||
|
||||
export const PROJECT_CONFIG_PATH = '.niancode/project.json';
|
||||
|
||||
@@ -22,6 +25,12 @@ function normalizeStringList(value: unknown): string[] {
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeProjectType(value: unknown): ProjectType {
|
||||
if (value === undefined) return 'custom';
|
||||
if (isProjectType(value)) return value;
|
||||
throw new Error('Invalid project type');
|
||||
}
|
||||
|
||||
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const raw = value as Partial<ProjectAgentConfig>;
|
||||
@@ -86,6 +95,7 @@ export function normalizeProjectConfig(value: unknown): ProjectConfig {
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
projectType: normalizeProjectType(raw.projectType),
|
||||
initialized,
|
||||
superpowersEnabled: raw.superpowersEnabled === true,
|
||||
defaultModel: typeof raw.defaultModel === 'string' && raw.defaultModel.trim()
|
||||
@@ -231,9 +241,9 @@ Last Updated By: user
|
||||
|
||||
export async function createInitialProjectConfig(
|
||||
projectPath: string,
|
||||
options?: { defaultModel?: string | null },
|
||||
options?: { defaultModel?: string | null; projectType?: ProjectType },
|
||||
): Promise<ProjectConfig> {
|
||||
const config = createProjectConfig();
|
||||
const config = createProjectConfig(new Date().toISOString(), options?.projectType ?? 'custom');
|
||||
if (typeof options?.defaultModel === 'string' && options.defaultModel.trim()) {
|
||||
config.defaultModel = options.defaultModel.trim();
|
||||
}
|
||||
@@ -242,15 +252,25 @@ export async function createInitialProjectConfig(
|
||||
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
||||
await writeFile(path.join(projectPath, 'VERSION.md'), initialVersionDocument(config.createdAt), { encoding: 'utf8', flag: 'wx' });
|
||||
await writeFile(path.join(projectPath, 'TASKS.md'), initialTaskDocument(), { encoding: 'utf8', flag: 'wx' });
|
||||
if (config.projectType !== 'custom') {
|
||||
await createPublishableProjectTemplate(projectPath, config.projectType);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function writeProjectConfig(projectPath: string, value: unknown): Promise<ProjectConfig> {
|
||||
const previous = await readProjectConfig(projectPath);
|
||||
if (previous.status !== 'valid') throw new Error('Project configuration is missing or invalid');
|
||||
const requestedProjectType = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as { projectType?: unknown }).projectType
|
||||
: undefined;
|
||||
if (requestedProjectType !== undefined && requestedProjectType !== previous.config.projectType) {
|
||||
throw new Error('已有项目类型不可更改');
|
||||
}
|
||||
const config = normalizeProjectConfig({
|
||||
...(value as object),
|
||||
schemaVersion: 1,
|
||||
projectType: previous.config.projectType,
|
||||
createdAt: previous.config.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, sep } from 'node:path';
|
||||
import type { ProjectConfig } from '../../shared/project-config';
|
||||
import type { ProjectConfig, ProjectType } from '../../shared/project-config';
|
||||
import { createInitialProjectConfig, readProjectConfig } from './project-config';
|
||||
|
||||
type StagedProjectEntries = {
|
||||
@@ -27,6 +27,7 @@ export type ProjectDirectoryInitializationResult = {
|
||||
export type ProjectDirectoryInitializationInput = {
|
||||
projectPath: string;
|
||||
defaultModel?: string | null;
|
||||
projectType?: ProjectType;
|
||||
allowExistingDirectory: boolean;
|
||||
};
|
||||
|
||||
@@ -210,6 +211,9 @@ export async function initializeProjectDirectory(
|
||||
await requireExistingDirectory(input.projectPath);
|
||||
const existing = await readProjectConfig(input.projectPath);
|
||||
if (existing.status === 'valid') {
|
||||
if (input.projectType !== undefined && input.projectType !== existing.config.projectType) {
|
||||
throw new Error('已有项目类型不可更改');
|
||||
}
|
||||
return { config: existing.config, reusedExistingConfig: true };
|
||||
}
|
||||
if (existing.status === 'invalid') {
|
||||
@@ -228,6 +232,7 @@ export async function initializeProjectDirectory(
|
||||
try {
|
||||
config = await createInitialProjectConfig(stagingPath, {
|
||||
defaultModel: input.defaultModel,
|
||||
projectType: input.projectType,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
|
||||
139
electron/opencode/project-template.ts
Normal file
139
electron/opencode/project-template.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { dirname, join } from 'node:path';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import type { ProjectType } from '../../shared/project-config';
|
||||
import vitePackageJson from './project-templates/vite/package.json';
|
||||
import vitePackageLock from './project-templates/vite/package-lock.json';
|
||||
|
||||
const VITE_CONFIG = `import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
});
|
||||
`;
|
||||
|
||||
const MINI_GAME_FILES: Record<string, string> = {
|
||||
'index.html': `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Makelore 小游戏</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #10233d; }
|
||||
body { display: grid; place-items: center; font-family: system-ui, sans-serif; }
|
||||
#game { width: 100%; height: 100%; touch-action: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" aria-label="Makelore 小游戏画布"></canvas>
|
||||
<script type="module" src="/game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'game.json': `${JSON.stringify({ deviceOrientation: 'portrait', showStatusBar: false }, null, 2)}\n`,
|
||||
'game.js': `const canvas = document.querySelector('#game');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
function render() {
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
|
||||
const gradient = context.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, '#10233d');
|
||||
gradient.addColorStop(1, '#3a5578');
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, width, height);
|
||||
|
||||
context.textAlign = 'center';
|
||||
context.fillStyle = '#ffffff';
|
||||
context.font = '700 28px system-ui, sans-serif';
|
||||
context.fillText('Makelore 小游戏', width / 2, height / 2 - 10);
|
||||
context.fillStyle = '#f6b45f';
|
||||
context.font = '16px system-ui, sans-serif';
|
||||
context.fillText('点击画布,开始创造', width / 2, height / 2 + 26);
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointerdown', (event) => {
|
||||
context.beginPath();
|
||||
context.fillStyle = '#f26a3d';
|
||||
context.arc(event.clientX, event.clientY, 18, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
window.addEventListener('resize', render);
|
||||
render();
|
||||
`,
|
||||
};
|
||||
|
||||
const MINI_PROGRAM_FILES: Record<string, string> = {
|
||||
'index.html': `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Makelore 小程序</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<script type="module" src="/pages/index/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'app.json': `${JSON.stringify({ pages: ['pages/index/index'], window: { navigationBarTitleText: 'Makelore 小程序' } }, null, 2)}\n`,
|
||||
'app.js': `export const app = {
|
||||
globalData: {
|
||||
platform: 'makelore',
|
||||
},
|
||||
};
|
||||
`,
|
||||
'pages/index/index.json': `${JSON.stringify({ navigationBarTitleText: '首页' }, null, 2)}\n`,
|
||||
'pages/index/index.css': `:root {
|
||||
color: #24364b;
|
||||
background: #f4f7fb;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body { margin: 0; }
|
||||
.page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||||
.card { width: min(420px, 100%); border: 1px solid #d8e1ec; border-radius: 20px; background: #fff; padding: 28px; text-align: center; box-shadow: 0 16px 48px rgb(36 54 75 / 10%); }
|
||||
button { border: 0; border-radius: 12px; background: #3a5578; color: #fff; padding: 12px 18px; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
`,
|
||||
'pages/index/index.js': `import { app } from '../../app.js';
|
||||
import './index.css';
|
||||
|
||||
const root = document.querySelector('#app');
|
||||
let count = 0;
|
||||
root.innerHTML = \`<section class="page"><div class="card"><p>运行于 \${app.globalData.platform}</p><h1>Makelore 小程序</h1><p id="count">已点击 0 次</p><button type="button">点我试试</button></div></section>\`;
|
||||
root.querySelector('button').addEventListener('click', () => {
|
||||
count += 1;
|
||||
root.querySelector('#count').textContent = \`已点击 \${count} 次\`;
|
||||
});
|
||||
`,
|
||||
};
|
||||
|
||||
function serializeJson(value: unknown): string {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export async function createPublishableProjectTemplate(
|
||||
projectPath: string,
|
||||
projectType: Exclude<ProjectType, 'custom'>,
|
||||
): Promise<void> {
|
||||
const files = projectType === 'mini_game' ? MINI_GAME_FILES : MINI_PROGRAM_FILES;
|
||||
const templateFiles: Record<string, string> = {
|
||||
'package.json': serializeJson(vitePackageJson),
|
||||
'package-lock.json': serializeJson(vitePackageLock),
|
||||
'vite.config.js': VITE_CONFIG,
|
||||
...files,
|
||||
};
|
||||
|
||||
for (const [relativePath, content] of Object.entries(templateFiles)) {
|
||||
const filePath = join(projectPath, relativePath);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content, { encoding: 'utf8', flag: 'wx' });
|
||||
}
|
||||
}
|
||||
1164
electron/opencode/project-templates/vite/package-lock.json
generated
Normal file
1164
electron/opencode/project-templates/vite/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
electron/opencode/project-templates/vite/package.json
Normal file
15
electron/opencode/project-templates/vite/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "makelore-project",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "npm@10.9.2",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,26 @@ import {
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
|
||||
import type { ProjectType } from '../../shared/project-config';
|
||||
import { readProjectConfig } from '../opencode/project-config';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const AdmZip = require('adm-zip') as typeof import('adm-zip');
|
||||
|
||||
const GENERATED_MANIFEST = (
|
||||
'schema_version: 1\n'
|
||||
+ 'kind: web\n'
|
||||
+ 'runtime: static\n'
|
||||
+ 'build:\n'
|
||||
+ ' preset: vite\n'
|
||||
+ ' package_manager: npm\n'
|
||||
+ ' entry: index.html\n'
|
||||
);
|
||||
type PublishableProjectType = Exclude<ProjectType, 'custom'>;
|
||||
|
||||
function generatedManifest(projectType: PublishableProjectType): string {
|
||||
return (
|
||||
'schema_version: 1\n'
|
||||
+ `project_type: ${projectType}\n`
|
||||
+ 'kind: web\n'
|
||||
+ 'runtime: static\n'
|
||||
+ 'build:\n'
|
||||
+ ' preset: vite\n'
|
||||
+ ' package_manager: npm\n'
|
||||
+ ' entry: index.html\n'
|
||||
);
|
||||
}
|
||||
const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0, 0);
|
||||
const FILE_MODE = 0o100644;
|
||||
const DEFAULT_LIMITS: ProjectPackageLimits = {
|
||||
@@ -125,6 +132,7 @@ export type StaticProjectPackageSummary = {
|
||||
excludedPaths: string[];
|
||||
manifest: {
|
||||
schema_version: 1;
|
||||
project_type: PublishableProjectType;
|
||||
kind: 'web';
|
||||
runtime: 'static';
|
||||
build: {
|
||||
@@ -177,9 +185,10 @@ export async function createStaticProjectPackage(input: {
|
||||
}): Promise<StaticProjectPackageSummary> {
|
||||
const limits = input.limits ?? DEFAULT_LIMITS;
|
||||
const projectPath = await resolveProjectDirectory(input.projectPath);
|
||||
const projectType = await readPublishableProjectType(projectPath);
|
||||
await validateViteProject(projectPath, limits);
|
||||
const scan = await scanProject(projectPath, limits);
|
||||
const manifestBytes = Buffer.from(GENERATED_MANIFEST, 'utf8');
|
||||
const manifestBytes = Buffer.from(generatedManifest(projectType), 'utf8');
|
||||
const sourceBytes = scan.sourceBytes + manifestBytes.length;
|
||||
if (sourceBytes > limits.maxSourceBytes) {
|
||||
throw new ProjectPackageError(
|
||||
@@ -228,6 +237,7 @@ export async function createStaticProjectPackage(input: {
|
||||
excludedPaths: scan.excludedPaths,
|
||||
manifest: {
|
||||
schema_version: 1,
|
||||
project_type: projectType,
|
||||
kind: 'web',
|
||||
runtime: 'static',
|
||||
build: {
|
||||
@@ -239,6 +249,23 @@ export async function createStaticProjectPackage(input: {
|
||||
};
|
||||
}
|
||||
|
||||
async function readPublishableProjectType(projectPath: string): Promise<PublishableProjectType> {
|
||||
const result = await readProjectConfig(projectPath);
|
||||
if (result.status === 'invalid') {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_CONFIG_INVALID',
|
||||
'项目配置无效,无法确认发布类型',
|
||||
);
|
||||
}
|
||||
if (result.status === 'missing' || result.config.projectType === 'custom') {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_TYPE_UNPUBLISHABLE',
|
||||
'自定义项目暂未配置发布方式,请新建小游戏或小程序项目',
|
||||
);
|
||||
}
|
||||
return result.config.projectType;
|
||||
}
|
||||
|
||||
async function resolveProjectDirectory(projectPath: string): Promise<string> {
|
||||
try {
|
||||
const resolved = await realpath(projectPath);
|
||||
|
||||
Reference in New Issue
Block a user