feat: 添加 nCode 组件及相关 API,支持创建、查询、更新和删除操作

This commit is contained in:
2026-03-03 14:17:06 +08:00
parent e8598e418d
commit cfb6a1cf11
19 changed files with 755 additions and 5 deletions

13
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"servers": {
"convex": {
"command": "npx",
"args": [
"-y",
"convex@latest",
"mcp",
"start"
]
}
}
}

View File

@@ -11,6 +11,7 @@
import type * as github_action from "../github/action.js"; import type * as github_action from "../github/action.js";
import type * as github_starrred from "../github/starrred.js"; import type * as github_starrred from "../github/starrred.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as nCode from "../nCode.js";
import type { import type {
ApiFromModules, ApiFromModules,
@@ -22,6 +23,7 @@ declare const fullApi: ApiFromModules<{
"github/action": typeof github_action; "github/action": typeof github_action;
"github/starrred": typeof github_starrred; "github/starrred": typeof github_starrred;
http: typeof http; http: typeof http;
nCode: typeof nCode;
}>; }>;
/** /**
@@ -50,4 +52,50 @@ export declare const internal: FilterApi<
FunctionReference<any, "internal"> FunctionReference<any, "internal">
>; >;
export declare const components: {}; export declare const components: {
nCode: {
action: {
create: FunctionReference<
"mutation",
"internal",
{
code: string;
data: any;
description: string;
slug?: string;
title: string;
type: string;
userId: string;
},
any
>;
deleteBySlug: FunctionReference<
"mutation",
"internal",
{ slug: string },
any
>;
getByCode: FunctionReference<"query", "internal", { code: string }, any>;
getBySlug: FunctionReference<"query", "internal", { slug: string }, any>;
getList: FunctionReference<
"query",
"internal",
{ type?: string; userId?: string },
any
>;
updateBySlug: FunctionReference<
"mutation",
"internal",
{
code?: string;
data?: any;
description?: string;
slug: string;
title?: string;
type?: string;
},
any
>;
};
};
};

View File

@@ -1,5 +1,3 @@
import { AuthConfig } from "convex/server";
export default { export default {
providers: [ providers: [
{ {

View File

@@ -0,0 +1,50 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as action from "../action.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
import { anyApi, componentsGeneric } from "convex/server";
const fullApi: ApiFromModules<{
action: typeof action;
}> = anyApi as any;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
> = anyApi as any;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
> = anyApi as any;
export const components = componentsGeneric() as unknown as {};

View File

@@ -0,0 +1,85 @@
/* eslint-disable */
/**
* Generated `ComponentApi` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type { FunctionReference } from "convex/server";
/**
* A utility for referencing a Convex component's exposed API.
*
* Useful when expecting a parameter like `components.myComponent`.
* Usage:
* ```ts
* async function myFunction(ctx: QueryCtx, component: ComponentApi) {
* return ctx.runQuery(component.someFile.someQuery, { ...args });
* }
* ```
*/
export type ComponentApi<Name extends string | undefined = string | undefined> =
{
action: {
create: FunctionReference<
"mutation",
"internal",
{
code: string;
data: any;
description: string;
slug?: string;
title: string;
type: string;
userId: string;
},
any,
Name
>;
deleteBySlug: FunctionReference<
"mutation",
"internal",
{ slug: string },
any,
Name
>;
getByCode: FunctionReference<
"query",
"internal",
{ code: string },
any,
Name
>;
getBySlug: FunctionReference<
"query",
"internal",
{ slug: string },
any,
Name
>;
getList: FunctionReference<
"query",
"internal",
{ type?: string; userId?: string },
any,
Name
>;
updateBySlug: FunctionReference<
"mutation",
"internal",
{
code?: string;
data?: any;
description?: string;
slug: string;
title?: string;
type?: string;
},
any,
Name
>;
};
};

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

View File

@@ -0,0 +1,156 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query: QueryBuilder<DataModel, "public"> = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery: QueryBuilder<DataModel, "internal"> =
internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation: MutationBuilder<DataModel, "public"> = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation: MutationBuilder<DataModel, "internal"> =
internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action: ActionBuilder<DataModel, "public"> = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction: ActionBuilder<DataModel, "internal"> =
internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction: HttpActionBuilder = httpActionGeneric;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*
* If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View File

@@ -0,0 +1,139 @@
import { query, mutation } from "./_generated/server.ts";
import { v } from "convex/values";
import { customAlphabet } from "nanoid";
const randomId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 10);
/**
* 查询 nCode 列表
*/
export const getList = query({
args: {
userId: v.optional(v.string()),
type: v.optional(v.string()),
},
handler: async (ctx, args) => {
// 优先使用索引过滤
if (args.userId) {
let results = await ctx.db
.query("shortLink")
.withIndex("by_userId", (q) => q.eq("userId", args.userId!))
.collect();
if (args.type) {
results = results.filter((item) => item.type === args.type);
}
return results;
}
if (args.type) {
return await ctx.db
.query("shortLink")
.withIndex("by_type", (q) => q.eq("type", args.type!))
.collect();
}
return await ctx.db.query("shortLink").collect();
},
});
/**
* 根据业务 id 查询单条记录
*/
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("shortLink")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
},
});
/**
* 根据 code 查询单条记录
*/
export const getByCode = query({
args: {
code: v.string(),
},
handler: async (ctx, args) => {
const result = await ctx.db
.query("shortLink")
.withIndex("by_code", (q) => q.eq("code", args.code))
.first();
return result;
},
});
/**
* 创建 nCode
*/
export const create = mutation({
args: {
slug: v.optional(v.string()),
code: v.string(),
type: v.string(),
data: v.any(),
title: v.string(),
description: v.string(),
userId: v.string(),
},
handler: async (ctx, args) => {
const slug = args.slug ?? randomId();
// 唯一性检查
const existing = await ctx.db
.query("shortLink")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.first();
if (existing) {
throw new Error(`nCode with slug "${slug}" already exists`);
}
const result = await ctx.db.insert("shortLink", {
version: "1.0", data: {
...args.data,
permission: {
share: 'public',
...(args.data?.permission || {})
}
}, ...args, slug
});
return result;
},
});
/**
* 更新 nCode
*/
export const updateBySlug = mutation({
args: {
slug: v.string(),
code: v.optional(v.string()),
type: v.optional(v.string()),
data: v.optional(v.any()),
title: v.optional(v.string()),
description: v.optional(v.string()),
},
handler: async (ctx, args) => {
const { slug, ...rest } = args;
const record = await ctx.db
.query("shortLink")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.first();
if (!record) throw new Error(`nCode with slug "${slug}" not found`);
await ctx.db.patch(record._id, rest);
},
});
/**
* 删除 nCode
*/
export const deleteBySlug = mutation({
args: {
slug: v.string(),
},
handler: async (ctx, args) => {
const record = await ctx.db
.query("shortLink")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (!record) throw new Error(`nCode with slug "${args.slug}" not found`);
await ctx.db.delete(record._id);
},
});

View File

@@ -0,0 +1,3 @@
import { defineComponent } from "convex/server";
const component = defineComponent("nCode");
export default component;

View File

@@ -0,0 +1,50 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export type Permission = {
share?: 'public' | 'private' | 'protected';
usernames?: string[]; // 仅当 type 为 'protected' 时有效
password?: string; // 仅当 type 为 'protected' 时有效
'expiration-time'?: string; // ISO 8601 格式的过期时间
}
export default defineSchema({
// Other tables here...
shortLink: defineTable({
// 对外暴露的唯一业务 IDnanoid 生成
slug: v.string(),
// 协作码,管理员才能编辑, 6-12 位随机字符串,唯一
code: v.string(),
// 码的类型link, agent默认值为 link
type: v.string(),
// 执行动作
data: v.object({
// action: v.string(), // 动作类型,如 "open_url", "run_agent" 等
permission: v.optional(v.any()), // 权限设置
link: v.optional(v.string()), // 仅当 type 为 'link' 时有效,表示跳转链接
}),
// 码的标题
title: v.string(),
// 描述定义
description: v.string(),
// 标签
tags: v.optional(v.array(v.string())),
// 绑定用户
userId: v.string(),
version: v.optional(v.string()), // 版本号,默认为 "1.0"
})
.index("by_slug", ["slug"])
.index("by_userId", ["userId"])
.index("by_code", ["code"])
.index("by_type", ["type"]),
desc: defineTable({
slug: v.string(),
// 卡片的正面图片 URL
front: v.optional(v.string()),
// 卡片的背面图片 URL
back: v.optional(v.string()),
status: v.optional(v.string()), // 生成状态active, completed, failed, sold(卖掉了)
}).index("by_slug", ["slug"]),
});

7
convex/convex.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineApp } from "convex/server";
import NCode from './components/n-code/convex.config.ts';
const app = defineApp();
app.use(NCode);
export default app;

View File

@@ -8,6 +8,14 @@ export const getList = query({
return results; return results;
}, },
}); });
export const get = query({
args: {
id: v.id("github_starred"),
},
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});
export const createStarred = mutation({ export const createStarred = mutation({
args: { args: {

85
convex/nCode.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* 顶层包装文件 —— 将 nCode Component 的函数暴露为公共 API。
*
* Convex Component 内部函数对客户端是 internal 级别,
* 必须在顶层 app 中用 ctx.runQuery / ctx.runMutation 做代理才能被客户端访问。
*
* 客户端调用示例:
* import { api } from "../convex/_generated/api";
* const list = useQuery(api.nCode.getList, { userId: "xxx" });
*/
import { query, mutation } from "./_generated/server.js";
import { components } from "./_generated/api.js";
import { v } from "convex/values";
/** 查询列表,支持按 userId / type 过滤 */
export const getList = query({
args: {
userId: v.optional(v.string()),
type: v.optional(v.string()),
},
handler: async (ctx, args) => {
return await ctx.runQuery(components.nCode.action.getList, args);
},
});
/** 根据 code 查单条记录 */
export const getByCode = query({
args: {
code: v.string(),
},
handler: async (ctx, args) => {
return await ctx.runQuery(components.nCode.action.getByCode, args);
},
});
/** 根据业务 id 查单条记录 */
export const getBySlug = query({
args: {
slug: v.string(),
},
handler: async (ctx, args) => {
return await ctx.runQuery(components.nCode.action.getBySlug, args);
},
});
/** 创建 nCode */
export const create = mutation({
args: {
slug: v.optional(v.string()),
code: v.string(),
type: v.string(),
data: v.object({}),
title: v.string(),
description: v.string(),
userId: v.string(),
},
handler: async (ctx, args) => {
return await ctx.runMutation(components.nCode.action.create, args);
},
});
/** 更新 nCode */
export const updateBySlug = mutation({
args: {
slug: v.string(),
code: v.optional(v.string()),
type: v.optional(v.string()),
data: v.optional(v.object({})),
title: v.optional(v.string()),
description: v.optional(v.string()),
},
handler: async (ctx, args) => {
return await ctx.runMutation(components.nCode.action.updateBySlug, args);
},
});
/** 删除 nCode */
export const deleteBySlug = mutation({
args: {
slug: v.string(),
},
handler: async (ctx, args) => {
return await ctx.runMutation(components.nCode.action.deleteBySlug, args);
},
});

View File

@@ -27,4 +27,5 @@ export default defineSchema({
expiresAt: v.optional(v.string()), expiresAt: v.optional(v.string()),
token: v.optional(v.string()), token: v.optional(v.string()),
}).index("token", ["token"]), }).index("token", ["token"]),
}); });

View File

@@ -4,7 +4,8 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "bunx convex dev" "dev": "bunx convex dev",
"mcp": "npx -y convex@latest mcp start --project-dir ./convex"
}, },
"files": [ "files": [
"convex", "convex",
@@ -23,7 +24,8 @@
"dependencies": { "dependencies": {
"@kevisual/auth": "^2.0.3", "@kevisual/auth": "^2.0.3",
"convex": "1.32.0", "convex": "1.32.0",
"jose": "^6.1.3" "jose": "^6.1.3",
"nanoid": "^5.1.6"
}, },
"exports": { "exports": {
".": "./convex/_generated/api.js" ".": "./convex/_generated/api.js"

10
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
jose: jose:
specifier: ^6.1.3 specifier: ^6.1.3
version: 6.1.3 version: 6.1.3
nanoid:
specifier: ^5.1.6
version: 5.1.6
devDependencies: devDependencies:
'@kevisual/types': '@kevisual/types':
specifier: ^0.0.12 specifier: ^0.0.12
@@ -225,6 +228,11 @@ packages:
jose@6.1.3: jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20}
hasBin: true
prettier@3.8.1: prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -381,6 +389,8 @@ snapshots:
jose@6.1.3: {} jose@6.1.3: {}
nanoid@5.1.6: {}
prettier@3.8.1: {} prettier@3.8.1: {}
undici-types@7.18.2: {} undici-types@7.18.2: {}

15
src/convex.ts Normal file
View File

@@ -0,0 +1,15 @@
import { api } from '../convex/_generated/api.js';
import { ConvexClient, AuthTokenFetcher } from "convex/browser";
const url = process.env["CONVEX_URL"]
const client = new ConvexClient(url!);
const token = process.env["KEVISUAL_CONVEX_TOKEN"]
const authTokenFetcher: AuthTokenFetcher = async ({ forceRefreshToken }: { forceRefreshToken: boolean }) => {
console.log("AuthTokenFetcher called, forceRefreshToken:", forceRefreshToken);
return token;
}
client.setAuth(authTokenFetcher, (isAuthenticated) => {
console.log("Auth isAuthenticated:", isAuthenticated);
});
export { client, api }

19
test/get-list.ts Normal file
View File

@@ -0,0 +1,19 @@
import { client, api } from '../src/convex.ts'
// client.query(api.nCode.getList, {}).then((res) => {
// console.log('getList', res)
// })
const createNCode = async () => {
const res = await client.mutation(api.nCode.create, {
code: Math.random().toString(36).substring(2, 8),
type: "link",
data: {},
title: "测试码",
description: "这是一个测试码",
userId: "test-user-id",
})
console.log('createNCode', res)
}
createNCode()

View File

@@ -18,6 +18,7 @@
}, },
"include": [ "include": [
"src/**/*", "src/**/*",
"test/**/*",
"convex/**/*", "convex/**/*",
], ],
} }