fix(web): 四个页面共用一份会话列表,空会话不入列
对话、星盘、星历、报告进同一 (app) 外壳,列表只拉一次。服务端不再列出空咨询;新建复用已有空会话。新标题改成「生时校正 · M月D日」,侧栏副标题用创建时间。
This commit is contained in:
@@ -10,6 +10,8 @@ type QueryResult = Readonly<{
|
||||
count?: number | null;
|
||||
}>;
|
||||
|
||||
type CmpOp = "eq" | "neq" | "lt" | "gt" | "lte" | "gte";
|
||||
|
||||
type Filter =
|
||||
| Readonly<{ kind: "eq"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "neq"; column: string; value: unknown }>
|
||||
@@ -18,7 +20,12 @@ type Filter =
|
||||
| Readonly<{ kind: "like"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "in"; column: string; value: readonly unknown[] }>
|
||||
| Readonly<{ kind: "is"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "notContains"; column: string; value: unknown }>;
|
||||
| Readonly<{ kind: "notContains"; column: string; value: unknown }>
|
||||
| Readonly<{ kind: "or"; expression: string }>;
|
||||
|
||||
export type PostgrestOrNode =
|
||||
| Readonly<{ kind: "cmp"; column: string; op: CmpOp; value: unknown }>
|
||||
| Readonly<{ kind: "and"; nodes: readonly PostgrestOrNode[] }>;
|
||||
|
||||
type Mutation =
|
||||
| Readonly<{ kind: "insert"; rows: readonly Record<string, unknown>[] }>
|
||||
@@ -48,6 +55,93 @@ export function formatOrderClause(
|
||||
.join(", ")}`;
|
||||
}
|
||||
|
||||
const SQL_CMP: Record<CmpOp, string> = {
|
||||
eq: "=",
|
||||
neq: "<>",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
lte: "<=",
|
||||
gte: ">=",
|
||||
};
|
||||
|
||||
function splitTopLevel(expression: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let start = 0;
|
||||
for (let index = 0; index < expression.length; index += 1) {
|
||||
const char = expression[index];
|
||||
if (char === "\"" && expression[index - 1] !== "\\") inString = !inString;
|
||||
if (inString) continue;
|
||||
if (char === "(") depth += 1;
|
||||
else if (char === ")") depth -= 1;
|
||||
else if (char === "," && depth === 0) {
|
||||
parts.push(expression.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
parts.push(expression.slice(start));
|
||||
return parts.map((part) => part.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parsePostgrestValue(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
if (trimmed === "null") return null;
|
||||
if (trimmed.startsWith("\"") || trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parsePostgrestTerm(term: string): PostgrestOrNode {
|
||||
const andMatch = /^and\(([\s\S]*)\)$/.exec(term);
|
||||
if (andMatch?.[1] != null) {
|
||||
return { kind: "and", nodes: splitTopLevel(andMatch[1]).map(parsePostgrestTerm) };
|
||||
}
|
||||
const cmp = /^([a-z_][a-z0-9_]*)\.(eq|neq|lt|gt|lte|gte)\.([\s\S]*)$/.exec(term);
|
||||
if (!cmp) throw new Error(`unsupported or filter: ${term}`);
|
||||
return {
|
||||
kind: "cmp",
|
||||
column: cmp[1] ?? "",
|
||||
op: (cmp[2] ?? "eq") as CmpOp,
|
||||
value: parsePostgrestValue(cmp[3] ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePostgrestOr(expression: string): PostgrestOrNode[] {
|
||||
return splitTopLevel(expression).map(parsePostgrestTerm);
|
||||
}
|
||||
|
||||
function compilePostgrestNode(
|
||||
node: PostgrestOrNode,
|
||||
parameters: unknown[],
|
||||
types: Map<string, string>,
|
||||
): string {
|
||||
if (node.kind === "and") {
|
||||
return `(${node.nodes.map((child) => compilePostgrestNode(child, parameters, types)).join(" and ")})`;
|
||||
}
|
||||
identifier(node.column);
|
||||
parameters.push(databaseValue(types.get(node.column), node.value));
|
||||
return `${identifier(node.column)} ${SQL_CMP[node.op]} $${parameters.length}`;
|
||||
}
|
||||
|
||||
export function compilePostgrestOr(
|
||||
expression: string,
|
||||
parameters: unknown[],
|
||||
types: Map<string, string>,
|
||||
): string {
|
||||
const nodes = parsePostgrestOr(expression);
|
||||
if (nodes.length === 0) return "true";
|
||||
if (nodes.length === 1) {
|
||||
const only = nodes[0];
|
||||
if (!only) return "true";
|
||||
return compilePostgrestNode(only, parameters, types);
|
||||
}
|
||||
return `(${nodes.map((node) => compilePostgrestNode(node, parameters, types)).join(" or ")})`;
|
||||
}
|
||||
|
||||
export function upsertConflictColumns(options?: { onConflict?: string }): string[] {
|
||||
return (options?.onConflict ?? "")
|
||||
.split(",")
|
||||
@@ -290,11 +384,21 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
|
||||
not(column: string, operator: string, value: unknown) {
|
||||
identifier(column);
|
||||
if (operator === "eq") {
|
||||
this.filters.push({ kind: "neq", column, value });
|
||||
return this;
|
||||
}
|
||||
if (operator !== "cs") throw new Error("unsupported not filter");
|
||||
this.filters.push({ kind: "notContains", column, value });
|
||||
return this;
|
||||
}
|
||||
|
||||
or(expression: string) {
|
||||
if (!expression.trim()) throw new Error("empty or filter");
|
||||
this.filters.push({ kind: "or", expression });
|
||||
return this;
|
||||
}
|
||||
|
||||
order(column: string, options: { ascending?: boolean } = {}) {
|
||||
identifier(column);
|
||||
this.ordering.push({ column, ascending: options.ascending !== false });
|
||||
@@ -342,6 +446,9 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
): string {
|
||||
if (this.filters.length === 0) return "";
|
||||
const parts = this.filters.map((filter) => {
|
||||
if (filter.kind === "or") {
|
||||
return compilePostgrestOr(filter.expression, parameters, types);
|
||||
}
|
||||
const column = identifier(filter.column);
|
||||
if (filter.kind === "is") {
|
||||
if (filter.value === null) return `${column} is null`;
|
||||
|
||||
Reference in New Issue
Block a user