refactor: rebuild desktop app with Tauri
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#141414" />
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background-color: var(--vscode-editor-background, var(--startup-background, #141414));
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(() => {
|
||||
try {
|
||||
const theme = localStorage.getItem("cursor-byok.theme") === "default-light"
|
||||
? "default-light"
|
||||
: "default-dark";
|
||||
const light = theme === "default-light";
|
||||
const background = light ? "#fcfcfc" : "#141414";
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.setProperty("--startup-background", background);
|
||||
document.documentElement.style.colorScheme = light ? "light" : "dark";
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", background);
|
||||
} catch {
|
||||
// The inline CSS already provides the dark-theme fallback.
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<title>Cursor BYOK</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>Cursor BYOK 需要 JavaScript 才能运行。</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.0",
|
||||
"description": "Cursor BYOK desktop management application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||
"i18n:scan": "STATIC_I18N_SCAN=true vite build",
|
||||
"build": "vite build",
|
||||
"check": "npm run typecheck && npm run typecheck:node && npm run build",
|
||||
"dev:web": "concurrently --kill-others --success first --names server,web \"cross-env CURSOR_CONSOLE_PROXY=http://127.0.0.1:1420 cargo run --manifest-path ../../server/Cargo.toml --bin cursor-server\" \"wait-on http-get://127.0.0.1:3000/__byok-api__/healthz && vite\"",
|
||||
"serve": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
"@iconify/json": "^2.2.519",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"echarts": "^6.1.0",
|
||||
"keepalive-for-react": "^5.0.11",
|
||||
"keepalive-for-react-router": "^5.0.7",
|
||||
"monaco-editor": "0.56.0",
|
||||
"react": "^19.2.8",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"zrender": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@babel/traverse": "^7.29.8",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/babel__traverse": "^7.28.0",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"code-inspector-plugin": "^1.6.6",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"sass": "^1.102.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"wait-on": "^9.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "3.4.14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { parse as parseJavaScript, type ParserPlugin } from "@babel/parser"
|
||||
import traverseModule from "@babel/traverse"
|
||||
import { normalizePath, type Plugin } from "vite"
|
||||
|
||||
// @ts-expect-error - CJS/ESM 模块互操作。
|
||||
const traverse = traverseModule.default ?? traverseModule
|
||||
|
||||
const SOURCE_LOCALE = "zh-CN"
|
||||
const SUPPORTED_LOCALES = ["zh-CN", "en-US"]
|
||||
const CHINESE_SOURCE_PATTERN = /[\u3400-\u9fff]/u
|
||||
const PLACEHOLDER_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
||||
const AUTO_IMPORT_NAME = "__staticI18nT"
|
||||
const AUTO_IMPORT = `import { t as ${AUTO_IMPORT_NAME} } from "/src/i18n/runtime";\n`
|
||||
|
||||
const BABEL_PLUGINS = [
|
||||
"jsx",
|
||||
"typescript",
|
||||
["classProperties", { decoratorsBeforeExport: false }],
|
||||
["classPrivateProperties", { decoratorsBeforeExport: false }],
|
||||
"classPrivateMethods",
|
||||
"topLevelAwait",
|
||||
"importAttributes"
|
||||
] as unknown as ParserPlugin[]
|
||||
|
||||
interface SourceRef {
|
||||
file: string
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
id: string
|
||||
source: string
|
||||
kind: MessageKind
|
||||
placeholders: string[]
|
||||
ref: SourceRef
|
||||
}
|
||||
|
||||
interface MergedEntry {
|
||||
source: string
|
||||
kind: MessageKind
|
||||
placeholders: string[]
|
||||
refs: SourceRef[]
|
||||
}
|
||||
|
||||
interface CallReplacement {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface BabelArgument {
|
||||
type: string
|
||||
value?: string
|
||||
properties?: BabelObjectProperty[]
|
||||
}
|
||||
|
||||
interface BabelObjectProperty {
|
||||
type: string
|
||||
computed?: boolean
|
||||
key?: { type: string; name?: string; value?: string }
|
||||
}
|
||||
|
||||
interface BabelCallNode {
|
||||
callee: { type: string; name?: string; start?: number | null; end?: number | null }
|
||||
arguments: BabelArgument[]
|
||||
loc?: { start: { line: number; column: number } | null } | null
|
||||
}
|
||||
|
||||
interface BabelCallPath {
|
||||
node: BabelCallNode
|
||||
scope: { getBinding(name: string): unknown }
|
||||
}
|
||||
|
||||
enum MessageKind {
|
||||
Text = "text",
|
||||
Template = "template",
|
||||
}
|
||||
|
||||
enum BabelNodeType {
|
||||
Identifier = "Identifier",
|
||||
StringLiteral = "StringLiteral",
|
||||
ObjectExpression = "ObjectExpression",
|
||||
ObjectProperty = "ObjectProperty",
|
||||
}
|
||||
|
||||
function hashMessageID(message: string): string {
|
||||
return createHash("sha256").update(message).digest("hex").slice(0, 16)
|
||||
}
|
||||
|
||||
function stripQuery(id: string): string {
|
||||
return id.split("?")[0]
|
||||
}
|
||||
|
||||
function isSourceFile(id: string): boolean {
|
||||
return /\.(?:js|jsx|ts|tsx)$/.test(stripQuery(id))
|
||||
}
|
||||
|
||||
function isExcludedFile(sourceRoot: string, id: string): boolean {
|
||||
const cleanID = normalizePath(stripQuery(id))
|
||||
if (cleanID.includes("/node_modules/")) return true
|
||||
const relativePath = normalizePath(path.relative(sourceRoot, cleanID))
|
||||
if (relativePath.startsWith("..")) return true
|
||||
return relativePath.startsWith("i18n/")
|
||||
}
|
||||
|
||||
function readJSONFile(filePath: string): Record<string, string> {
|
||||
if (!fs.existsSync(filePath)) return {}
|
||||
const raw = fs.readFileSync(filePath, "utf8").trim()
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
}
|
||||
|
||||
function writeJSONFile(filePath: string, payload: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function buildRef(
|
||||
filePath: string,
|
||||
sourceRoot: string,
|
||||
loc: { line: number; column: number } | null
|
||||
): SourceRef {
|
||||
return {
|
||||
file: normalizePath(path.relative(sourceRoot, filePath)),
|
||||
line: loc?.line ?? 1,
|
||||
column: loc?.column ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLocation(
|
||||
filePath: string,
|
||||
sourceRoot: string,
|
||||
loc: { line: number; column: number } | null
|
||||
): string {
|
||||
const ref = buildRef(filePath, sourceRoot, loc)
|
||||
return `${ref.file}:${ref.line}:${ref.column}`
|
||||
}
|
||||
|
||||
function placeholderNames(message: string): string[] {
|
||||
return Array.from(message.matchAll(PLACEHOLDER_PATTERN), (match) => match[1])
|
||||
}
|
||||
|
||||
function assertUniquePlaceholders(
|
||||
placeholders: string[],
|
||||
location: string,
|
||||
message: string
|
||||
): void {
|
||||
const duplicate = placeholders.find(
|
||||
(name, index) => placeholders.indexOf(name) !== index
|
||||
)
|
||||
if (!duplicate) return
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 重复使用占位符 {${duplicate}}:${JSON.stringify(message)}`
|
||||
)
|
||||
}
|
||||
|
||||
function objectParameterNames(argument: BabelArgument, location: string): string[] {
|
||||
if (argument.type !== BabelNodeType.ObjectExpression) {
|
||||
throw new Error(`[static-i18n] ${location} 的 t params 必须是静态对象字面量`)
|
||||
}
|
||||
|
||||
const names: string[] = []
|
||||
for (const property of argument.properties ?? []) {
|
||||
if (property.type !== BabelNodeType.ObjectProperty || property.computed) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 的 t params 不允许展开、计算属性或方法`
|
||||
)
|
||||
}
|
||||
const key = property.key
|
||||
const name =
|
||||
key?.type === BabelNodeType.Identifier
|
||||
? key.name
|
||||
: key?.type === BabelNodeType.StringLiteral
|
||||
? key.value
|
||||
: undefined
|
||||
if (!name) {
|
||||
throw new Error(`[static-i18n] ${location} 的 t params key 必须是静态名称`)
|
||||
}
|
||||
if (names.includes(name)) {
|
||||
throw new Error(`[static-i18n] ${location} 的 t params 重复提供 ${name}`)
|
||||
}
|
||||
names.push(name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function validateSourceCall(
|
||||
filePath: string,
|
||||
sourceRoot: string,
|
||||
source: string,
|
||||
args: BabelArgument[],
|
||||
loc: { line: number; column: number } | null
|
||||
): string[] {
|
||||
const location = sourceLocation(filePath, sourceRoot, loc)
|
||||
if (!CHINESE_SOURCE_PATTERN.test(source)) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 的 t source 必须包含中文:${JSON.stringify(source)}`
|
||||
)
|
||||
}
|
||||
|
||||
const placeholders = placeholderNames(source)
|
||||
assertUniquePlaceholders(placeholders, location, source)
|
||||
|
||||
if (!placeholders.length) {
|
||||
if (args.length !== 1) {
|
||||
throw new Error(`[static-i18n] ${location} 的无参数消息不能传入 params`)
|
||||
}
|
||||
return placeholders
|
||||
}
|
||||
|
||||
if (args.length !== 2) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 必须为占位符传入一个具名 params 对象`
|
||||
)
|
||||
}
|
||||
|
||||
const parameters = objectParameterNames(args[1], location)
|
||||
const missing = placeholders.filter((name) => !parameters.includes(name))
|
||||
const extra = parameters.filter((name) => !placeholders.includes(name))
|
||||
if (missing.length || extra.length) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 的 params 与占位符不一致` +
|
||||
`${missing.length ? `,缺少:${missing.join(", ")}` : ""}` +
|
||||
`${extra.length ? `,多余:${extra.join(", ")}` : ""}`
|
||||
)
|
||||
}
|
||||
return placeholders
|
||||
}
|
||||
|
||||
function buildMessageRecord(
|
||||
filePath: string,
|
||||
sourceRoot: string,
|
||||
source: string,
|
||||
placeholders: string[],
|
||||
loc: { line: number; column: number } | null
|
||||
): MessageRecord {
|
||||
return {
|
||||
id: hashMessageID(source),
|
||||
source,
|
||||
kind: placeholders.length ? MessageKind.Template : MessageKind.Text,
|
||||
placeholders,
|
||||
ref: buildRef(filePath, sourceRoot, loc)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeMessageRecords(records: MessageRecord[]) {
|
||||
const entries = new Map<string, MergedEntry>()
|
||||
for (const record of records) {
|
||||
const current = entries.get(record.id)
|
||||
if (!current) {
|
||||
entries.set(record.id, {
|
||||
source: record.source,
|
||||
kind: record.kind,
|
||||
placeholders: record.placeholders,
|
||||
refs: [record.ref]
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (current.source !== record.source) {
|
||||
throw new Error(
|
||||
`[static-i18n] Message id collision for ${record.id}: ${current.source} <> ${record.source}`
|
||||
)
|
||||
}
|
||||
current.refs.push(record.ref)
|
||||
}
|
||||
|
||||
const sortedEntries: Record<string, MergedEntry> = {}
|
||||
for (const id of Array.from(entries.keys()).sort()) {
|
||||
const entry = entries.get(id)!
|
||||
sortedEntries[id] = {
|
||||
...entry,
|
||||
refs: entry.refs.sort(
|
||||
(a, b) =>
|
||||
a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column
|
||||
)
|
||||
}
|
||||
}
|
||||
return { entries: sortedEntries }
|
||||
}
|
||||
|
||||
function mergeLocaleMessages(
|
||||
existingMessages: Record<string, string>,
|
||||
catalogEntries: Record<string, MergedEntry>,
|
||||
locale: string
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(catalogEntries).map(([id, entry]) => [
|
||||
id,
|
||||
locale === SOURCE_LOCALE ? entry.source : existingMessages[id] ?? ""
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function validateTranslations(
|
||||
sourceRoot: string,
|
||||
entries: Record<string, MergedEntry>
|
||||
): void {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
if (locale === SOURCE_LOCALE) continue
|
||||
const localePath = path.join(sourceRoot, "i18n/locales", `${locale}.json`)
|
||||
const messages = readJSONFile(localePath)
|
||||
for (const [id, entry] of Object.entries(entries)) {
|
||||
const translation = messages[id]
|
||||
if (!translation) continue
|
||||
const actual = placeholderNames(translation)
|
||||
assertUniquePlaceholders(actual, `${localePath}:${id}`, translation)
|
||||
const missing = entry.placeholders.filter((name) => !actual.includes(name))
|
||||
const extra = actual.filter((name) => !entry.placeholders.includes(name))
|
||||
if (!missing.length && !extra.length) continue
|
||||
throw new Error(
|
||||
`[static-i18n] ${localePath}:${id} 的译文占位符与 source 不一致` +
|
||||
`${missing.length ? `,缺少:${missing.join(", ")}` : ""}` +
|
||||
`${extra.length ? `,多余:${extra.join(", ")}` : ""}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function walkSourceFiles(dirPath: string, visitor: (filePath: string) => void): void {
|
||||
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
||||
const nextPath = path.join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== "node_modules") walkSourceFiles(nextPath, visitor)
|
||||
} else {
|
||||
visitor(nextPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseProgram(code: string, filename: string) {
|
||||
try {
|
||||
return parseJavaScript(code, {
|
||||
sourceType: "module",
|
||||
sourceFilename: filename,
|
||||
plugins: [...BABEL_PLUGINS]
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[static-i18n] Failed to parse ${filename}: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeTCalls(
|
||||
code: string,
|
||||
filePath: string,
|
||||
sourceRoot: string
|
||||
): { records: MessageRecord[]; replacements: CallReplacement[] } {
|
||||
const records: MessageRecord[] = []
|
||||
const replacements: CallReplacement[] = []
|
||||
const ast = parseProgram(code, filePath)
|
||||
|
||||
traverse(ast, {
|
||||
CallExpression(callPath: BabelCallPath) {
|
||||
const node = callPath.node
|
||||
if (node.callee.type !== BabelNodeType.Identifier || node.callee.name !== "t") return
|
||||
|
||||
let loc: { line: number; column: number } | null = null
|
||||
if (node.loc?.start) {
|
||||
loc = { line: node.loc.start.line, column: node.loc.start.column + 1 }
|
||||
}
|
||||
const location = sourceLocation(filePath, sourceRoot, loc)
|
||||
if (callPath.scope.getBinding("t")) {
|
||||
throw new Error(
|
||||
`[static-i18n] ${location} 的 t 是保留全局函数,请删除本地声明或 import`
|
||||
)
|
||||
}
|
||||
if (!node.arguments.length) {
|
||||
throw new Error(`[static-i18n] ${location} 的 t 调用缺少中文 source`)
|
||||
}
|
||||
|
||||
const firstArg = node.arguments[0]
|
||||
if (firstArg.type !== BabelNodeType.StringLiteral || firstArg.value === undefined) {
|
||||
throw new Error(`[static-i18n] ${location} 的 t source 必须是静态字符串字面量`)
|
||||
}
|
||||
const placeholders = validateSourceCall(
|
||||
filePath,
|
||||
sourceRoot,
|
||||
firstArg.value,
|
||||
node.arguments,
|
||||
loc
|
||||
)
|
||||
records.push(
|
||||
buildMessageRecord(filePath, sourceRoot, firstArg.value, placeholders, loc)
|
||||
)
|
||||
|
||||
const { start, end } = node.callee
|
||||
if (start == null || end == null) {
|
||||
throw new Error(`[static-i18n] ${location} 无法定位 t 调用`)
|
||||
}
|
||||
replacements.push({ start, end })
|
||||
}
|
||||
})
|
||||
return { records, replacements }
|
||||
}
|
||||
|
||||
function collectCatalog(sourceRoot: string) {
|
||||
const records: MessageRecord[] = []
|
||||
walkSourceFiles(sourceRoot, (filePath) => {
|
||||
const cleanPath = normalizePath(filePath)
|
||||
if (!isSourceFile(cleanPath) || isExcludedFile(sourceRoot, cleanPath)) return
|
||||
records.push(
|
||||
...analyzeTCalls(fs.readFileSync(cleanPath, "utf8"), cleanPath, sourceRoot).records
|
||||
)
|
||||
})
|
||||
return mergeMessageRecords(records)
|
||||
}
|
||||
|
||||
function syncCatalogFiles(
|
||||
sourceRoot: string,
|
||||
catalog: ReturnType<typeof collectCatalog>
|
||||
): void {
|
||||
writeJSONFile(path.join(sourceRoot, "i18n/generated/catalog.json"), catalog)
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localePath = path.join(sourceRoot, "i18n/locales", `${locale}.json`)
|
||||
writeJSONFile(
|
||||
localePath,
|
||||
mergeLocaleMessages(readJSONFile(localePath), catalog.entries, locale)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function injectRuntimeImport(code: string, replacements: CallReplacement[]): string {
|
||||
let transformed = code
|
||||
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
||||
transformed =
|
||||
transformed.slice(0, replacement.start) +
|
||||
AUTO_IMPORT_NAME +
|
||||
transformed.slice(replacement.end)
|
||||
}
|
||||
return AUTO_IMPORT + transformed
|
||||
}
|
||||
|
||||
export function staticI18nPlugin(): Plugin {
|
||||
let sourceRoot = path.join(process.cwd(), "src")
|
||||
const shouldScan =
|
||||
process.argv.includes("--scan") || process.env.STATIC_I18N_SCAN === "true"
|
||||
|
||||
return {
|
||||
name: "cursor-byok-static-i18n",
|
||||
enforce: "pre",
|
||||
configResolved(config) {
|
||||
sourceRoot = path.join(config.root, "src")
|
||||
},
|
||||
buildStart() {
|
||||
const catalog = collectCatalog(sourceRoot)
|
||||
validateTranslations(sourceRoot, catalog.entries)
|
||||
if (shouldScan) syncCatalogFiles(sourceRoot, catalog)
|
||||
},
|
||||
transform(code, id) {
|
||||
if (!isSourceFile(id) || isExcludedFile(sourceRoot, id)) return null
|
||||
const analysis = analyzeTCalls(code, normalizePath(stripQuery(id)), sourceRoot)
|
||||
if (!analysis.replacements.length) return null
|
||||
return { code: injectRuntimeImport(code, analysis.replacements), map: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "cursor_byok_desktop"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
cursor-server = { path = "../../../server" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
tokio-util = "0.7"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
url = "2"
|
||||
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]);
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
|
||||
.expect("failed to build Tauri application")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Cursor BYOK 主窗口的最小权限",
|
||||
"remote": {
|
||||
"urls": [
|
||||
"http://127.0.0.1:*",
|
||||
"http://localhost:*"
|
||||
]
|
||||
},
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"allow-open-terminal-with-command",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"autostart:default",
|
||||
"process:allow-restart",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-open-terminal-with-command"
|
||||
description = "Enables the open_terminal_with_command command without any pre-configured scope."
|
||||
commands.allow = ["open_terminal_with_command"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-open-terminal-with-command"
|
||||
description = "Denies the open_terminal_with_command command without any pre-configured scope."
|
||||
commands.deny = ["open_terminal_with_command"]
|
||||
@@ -0,0 +1,229 @@
|
||||
use std::{
|
||||
process::Command,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Mutex,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Json},
|
||||
http::StatusCode,
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use tauri::{
|
||||
async_runtime::JoinHandle, webview::Color, AppHandle, Manager, RunEvent, WebviewUrl,
|
||||
WebviewWindowBuilder,
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[cfg(dev)]
|
||||
use cursor_server::config::ConsoleSource;
|
||||
use cursor_server::{App, Config, Result};
|
||||
|
||||
#[cfg(not(dev))]
|
||||
use crate::frontend;
|
||||
use crate::tray;
|
||||
|
||||
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
|
||||
|
||||
struct DesktopRuntime {
|
||||
shutdown: CancellationToken,
|
||||
server: Mutex<Option<JoinHandle<Result<()>>>>,
|
||||
exiting: AtomicBool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_terminal_with_command(command: String) -> tauri::Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = command;
|
||||
Command::new("open").args(["-a", "Terminal"]).status()?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "cmd", "/K", &command])
|
||||
.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = command;
|
||||
Err(tauri::Error::from(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"terminal guidance is unsupported on this platform",
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OpenExternalUrlRequest {
|
||||
url: String,
|
||||
}
|
||||
|
||||
fn open_external_url(app: &AppHandle, url: &str) -> std::result::Result<(), String> {
|
||||
let parsed = url::Url::parse(url).map_err(|error| format!("invalid URL: {error}"))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||
return Err("only absolute HTTP and HTTPS URLs are allowed".into());
|
||||
}
|
||||
|
||||
app.opener()
|
||||
.open_url(parsed.as_str(), None::<&str>)
|
||||
.map_err(|error| format!("failed to open URL: {error}"))
|
||||
}
|
||||
|
||||
async fn open_external_url_handler(
|
||||
Extension(app): Extension<AppHandle>,
|
||||
Json(request): Json<OpenExternalUrlRequest>,
|
||||
) -> std::result::Result<StatusCode, (StatusCode, String)> {
|
||||
open_external_url(&app, &request.url)
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(|error| (StatusCode::BAD_REQUEST, error))
|
||||
}
|
||||
|
||||
fn desktop_api_router(app: AppHandle) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/__byok-api__/api/desktop/open-external-url",
|
||||
post(open_external_url_handler),
|
||||
)
|
||||
.layer(Extension(app))
|
||||
}
|
||||
|
||||
fn create_main_window(app: &AppHandle, address: std::net::SocketAddr) -> tauri::Result<()> {
|
||||
let url = format!("http://{address}/__byok-api__/")
|
||||
.parse()
|
||||
.expect("local frontend URL");
|
||||
let builder = WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, WebviewUrl::External(url))
|
||||
.title("Cursor BYOK")
|
||||
.inner_size(820.0, 558.0)
|
||||
.min_inner_size(820.0, 558.0)
|
||||
.center()
|
||||
.background_color(Color(20, 20, 20, 255))
|
||||
.decorations(cfg!(target_os = "macos"))
|
||||
.shadow(true)
|
||||
.resizable(true);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.hidden_title(true);
|
||||
|
||||
builder.build()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "cursor_server=info".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let app = tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![open_terminal_with_command])
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||
tray::show_main_window(app);
|
||||
}))
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(|app| {
|
||||
app.handle().plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))?;
|
||||
let config = Config::desktop()?;
|
||||
#[cfg(dev)]
|
||||
let config = {
|
||||
let mut config = config;
|
||||
config.console = Some(ConsoleSource::Proxy(
|
||||
"http://127.0.0.1:1420"
|
||||
.parse()
|
||||
.expect("Vite development URL"),
|
||||
));
|
||||
config
|
||||
};
|
||||
let server = tauri::async_runtime::block_on(App::new(config))?
|
||||
.merge_router(desktop_api_router(app.handle().clone()));
|
||||
#[cfg(not(dev))]
|
||||
let server = server.merge_router(frontend::router(app.handle().clone()));
|
||||
let listener = tauri::async_runtime::block_on(server.bind())?;
|
||||
let address = listener.local_addr()?;
|
||||
tauri::async_runtime::block_on(server.harness().cleanup_stale_settings())?;
|
||||
let shutdown = CancellationToken::new();
|
||||
let server_shutdown = shutdown.clone();
|
||||
let app_handle = app.handle().clone();
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
let result = server.serve_on(listener, server_shutdown).await;
|
||||
if let Err(error) = &result {
|
||||
tracing::error!(%error, "desktop server stopped unexpectedly");
|
||||
app_handle.exit(1);
|
||||
}
|
||||
result
|
||||
});
|
||||
app.manage(DesktopRuntime {
|
||||
shutdown,
|
||||
server: Mutex::new(Some(task)),
|
||||
exiting: AtomicBool::new(false),
|
||||
});
|
||||
create_main_window(app.handle(), address)?;
|
||||
tray::create(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("failed to build Cursor BYOK desktop app");
|
||||
|
||||
app.run(|app, event| match event {
|
||||
RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} if label == MAIN_WINDOW_LABEL => {
|
||||
let runtime = app.state::<DesktopRuntime>();
|
||||
if !runtime.exiting.load(Ordering::Acquire) {
|
||||
api.prevent_close();
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
RunEvent::ExitRequested { api, .. } => {
|
||||
let runtime = app.state::<DesktopRuntime>();
|
||||
if !runtime.exiting.swap(true, Ordering::AcqRel) {
|
||||
api.prevent_exit();
|
||||
runtime.shutdown.cancel();
|
||||
let server = runtime.server.lock().expect("server lock poisoned").take();
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(server) = server {
|
||||
match tokio::time::timeout(Duration::from_secs(11), server).await {
|
||||
Ok(Ok(Ok(()))) => {}
|
||||
Ok(Ok(Err(error))) => {
|
||||
tracing::error!(%error, "desktop server shutdown failed")
|
||||
}
|
||||
Ok(Err(error)) => tracing::error!(%error, "desktop server task failed"),
|
||||
Err(_) => tracing::warn!("desktop server shutdown timed out"),
|
||||
}
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Reopen {
|
||||
has_visible_windows: false,
|
||||
..
|
||||
} => tray::show_main_window(app),
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Serves Tauri's embedded frontend assets through the local HTTP server.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{header, HeaderValue, Response, StatusCode, Uri},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use tauri::AppHandle;
|
||||
|
||||
pub(crate) fn router(app: AppHandle) -> Router {
|
||||
Router::new()
|
||||
.route("/__byok-api__/", get(asset))
|
||||
.route("/__byok-api__/{*path}", get(asset))
|
||||
.with_state(app)
|
||||
}
|
||||
|
||||
async fn asset(State(app): State<AppHandle>, uri: Uri) -> Response<Body> {
|
||||
let path = uri
|
||||
.path()
|
||||
.strip_prefix("/__byok-api__/")
|
||||
.filter(|path| !path.is_empty())
|
||||
.unwrap_or("index.html");
|
||||
let Some(asset) = app.asset_resolver().get(path.to_string()) else {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
.expect("static not-found response");
|
||||
};
|
||||
let mut response = Response::new(Body::from(asset.bytes));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(&asset.mime_type)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
||||
);
|
||||
if let Some(csp) = asset.csp_header.and_then(|value| value.parse().ok()) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_SECURITY_POLICY, csp);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod desktop;
|
||||
#[cfg(not(dev))]
|
||||
mod frontend;
|
||||
mod tray;
|
||||
|
||||
pub use desktop::run;
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
cursor_byok_desktop::run();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
App, AppHandle, Manager,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent};
|
||||
|
||||
use crate::desktop::MAIN_WINDOW_LABEL;
|
||||
|
||||
const OPEN_MENU_ID: &str = "tray-open";
|
||||
const QUIT_MENU_ID: &str = "tray-quit";
|
||||
|
||||
pub fn create(app: &mut App) -> tauri::Result<()> {
|
||||
let open = MenuItem::with_id(app, OPEN_MENU_ID, "打开 Cursor BYOK", true, None::<&str>)?;
|
||||
let separator = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, QUIT_MENU_ID, "退出", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&open, &separator, &quit])?;
|
||||
|
||||
TrayIconBuilder::with_id("main")
|
||||
.icon(tauri::include_image!("./icons/32x32.png"))
|
||||
.tooltip("Cursor BYOK")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(cfg!(target_os = "macos"))
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
OPEN_MENU_ID => show_main_window(app),
|
||||
QUIT_MENU_ID => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main_window(tray.app_handle());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = (tray, event);
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show_main_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cursor BYOK",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.cursorbyok.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; connect-src 'self' http://127.0.0.1:*",
|
||||
"dangerousDisableAssetCspModification": [
|
||||
"style-src"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/linux-32x32.png",
|
||||
"icons/linux-128x128.png",
|
||||
"icons/linux-256x256.png",
|
||||
"icons/linux-512x512.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc1MEY2RjQzRjA0MjhCMDUKUldRRmkwTHdRMjhQZGJJam9zT1NwcW83L1V4ckJvbkQzQ0FYVFZ6MGRpa1N2NVFkUHk1dW14c1AK",
|
||||
"endpoints": [
|
||||
"https://github.com/leookun/cursor-byok/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"bundle": {
|
||||
"targets": ["app"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { MessageProvider } from "./components/ui/MessageProvider";
|
||||
import { useMessage } from "./components/ui/message";
|
||||
import { AppFrame } from "./layouts/AppFrame";
|
||||
import { AppLayout } from "./layouts/AppLayout";
|
||||
import { CallsPage } from "./pages/CallsPage";
|
||||
import { CallDetailsPage } from "./pages/CallDetailsPage";
|
||||
import { CursorSettingsPage } from "./pages/CursorSettingsPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { ProvidersPage } from "./pages/ProvidersPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { checkForUpdate, hasNativeAppLifecycle } from "./native/appLifecycle";
|
||||
import { useAppStore } from "./store/appStore";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="calls/:callId" element={<CallDetailsPage />} />
|
||||
<Route element={<AppFrame />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="calls" element={<CallsPage />} />
|
||||
<Route path="harness/cursor" element={<CursorSettingsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
<AppMessages />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppMessages() {
|
||||
const { error } = useAppStore();
|
||||
const previousError = useRef<string | null>(null);
|
||||
const showMessage = useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
if (error && error !== previousError.current) showMessage(error);
|
||||
previousError.current = error;
|
||||
}, [error, showMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNativeAppLifecycle()) return;
|
||||
void checkForUpdate().then(async (update) => {
|
||||
if (!update) return;
|
||||
showMessage(t("发现新版本 {version},可在设置中安装", { version: update.version }), { duration: 6_000 });
|
||||
await update.close();
|
||||
}).catch(() => {
|
||||
// Startup checks are best-effort; manual checks in Settings report errors.
|
||||
});
|
||||
}, [showMessage]);
|
||||
|
||||
return <MessageProvider />;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { AdRuntime } from "./components/ads/types";
|
||||
|
||||
export type ProviderType = "openai-chat" | "openai-responses" | "anthropic";
|
||||
|
||||
export interface Provider {
|
||||
provider_id: number;
|
||||
name: string;
|
||||
provider_type: ProviderType;
|
||||
base_url: string;
|
||||
has_api_key: boolean;
|
||||
custom_headers: Record<string, string | null>;
|
||||
extra_params: Record<string, unknown>;
|
||||
created_at_ms: number;
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface ProviderInput {
|
||||
name: string;
|
||||
provider_type: ProviderType;
|
||||
base_url: string;
|
||||
api_key?: string;
|
||||
custom_headers: Record<string, string | null>;
|
||||
extra_params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
model_hash: string;
|
||||
provider_id: number;
|
||||
model_id: string;
|
||||
display_name: string;
|
||||
endpoint_type: ProviderType;
|
||||
request_url: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
context_window_tokens: number | null;
|
||||
max_output_tokens: number | null;
|
||||
reasoning_enabled: boolean;
|
||||
reasoning_effort: string | null;
|
||||
supports_image_generation: boolean;
|
||||
created_at_ms: number;
|
||||
updated_at_ms: number;
|
||||
}
|
||||
|
||||
export interface ModelInput {
|
||||
model_id: string;
|
||||
display_name: string;
|
||||
endpoint_type: ProviderType;
|
||||
request_url: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
context_window_tokens: number | null;
|
||||
max_output_tokens: number | null;
|
||||
reasoning_enabled: boolean;
|
||||
reasoning_effort: string | null;
|
||||
supports_image_generation: boolean;
|
||||
}
|
||||
|
||||
export type CaState = "missing" | "untrusted" | "ready" | "invalid" | "unsupported";
|
||||
export type IntegrationState = "disabled" | "enabled" | "degraded";
|
||||
export interface CursorHarnessStatus {
|
||||
platform: string;
|
||||
ca: CaState;
|
||||
configured_models: number;
|
||||
enabled_models: number;
|
||||
integration: IntegrationState;
|
||||
proxy_url: string | null;
|
||||
ca_install_command: string | null;
|
||||
}
|
||||
|
||||
export interface PortSettings {
|
||||
proxy_port: number;
|
||||
service_port: number;
|
||||
}
|
||||
|
||||
export interface StatisticsStorage {
|
||||
bytes: number;
|
||||
call_count: number;
|
||||
trace_count: number;
|
||||
}
|
||||
|
||||
export type ProxyMode = "system" | "custom";
|
||||
|
||||
export interface ProxySettings {
|
||||
mode: ProxyMode;
|
||||
address: string;
|
||||
auth_enabled: boolean;
|
||||
username: string;
|
||||
has_password: boolean;
|
||||
}
|
||||
|
||||
export interface ProxySettingsInput {
|
||||
mode: ProxyMode;
|
||||
address: string;
|
||||
auth_enabled: boolean;
|
||||
username: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
llm_calls: number;
|
||||
successful_calls: number;
|
||||
failed_calls: number;
|
||||
token_usage: number;
|
||||
prompt_tokens: number;
|
||||
input_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
output_tokens: number;
|
||||
}
|
||||
|
||||
export type TokenUsageGranularity = "minute" | "hour" | "day";
|
||||
|
||||
export interface OverviewTokenUsageBucket {
|
||||
bucket_start_ms: number;
|
||||
input_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
output_tokens: number;
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
metrics: OverviewMetrics;
|
||||
token_usage_granularity: TokenUsageGranularity;
|
||||
token_usage_series: OverviewTokenUsageBucket[];
|
||||
}
|
||||
|
||||
export type ProviderSelection =
|
||||
| { kind: "existing"; provider_id: number }
|
||||
| { kind: "new"; input: ProviderInput };
|
||||
|
||||
export interface LlmCall {
|
||||
call_kind: "provider_llm" | "cursor_official";
|
||||
route: "local_byok" | "cursor_official";
|
||||
call_id: string;
|
||||
run_id: string;
|
||||
conversation_id: string;
|
||||
provider_call_index: number;
|
||||
model_hash: string | null;
|
||||
provider_type: string;
|
||||
provider_url: string;
|
||||
request_type: string;
|
||||
request_url: string;
|
||||
model_id: string;
|
||||
display_name: string;
|
||||
reasoning_effort: string | null;
|
||||
fast: boolean | null;
|
||||
status: string;
|
||||
finish_reason: string | null;
|
||||
created_at_ms: number;
|
||||
ttfb_ms: number | null;
|
||||
ttft_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
input_tokens: number | null;
|
||||
output_tokens: number | null;
|
||||
total_tokens: number | null;
|
||||
cache_read_tokens: number | null;
|
||||
cache_write_tokens: number | null;
|
||||
reasoning_tokens: number | null;
|
||||
message_count: number;
|
||||
tool_count: number;
|
||||
http_status: number | null;
|
||||
error_kind: string | null;
|
||||
error_message: string | null;
|
||||
detailed: boolean;
|
||||
}
|
||||
|
||||
export interface CallDetail {
|
||||
call: LlmCall;
|
||||
request: { headers: unknown; body: unknown; byte_count: number } | null;
|
||||
response_chunks: Array<{ seq: number; received_offset_ms: number; data: string; byte_count: number }>;
|
||||
cursor_trace: {
|
||||
trace: {
|
||||
request_id: string;
|
||||
conversation_id: string | null;
|
||||
route: "local_byok" | "cursor_official";
|
||||
model_id: string | null;
|
||||
status: string;
|
||||
request_bytes: number;
|
||||
response_bytes: number;
|
||||
response_event_count: number;
|
||||
http_status: number | null;
|
||||
received_at_ms: number;
|
||||
first_response_at_ms: number | null;
|
||||
finished_at_ms: number | null;
|
||||
error_message: string | null;
|
||||
};
|
||||
artifacts: Array<{
|
||||
seq: number;
|
||||
artifact_type: string;
|
||||
source: string;
|
||||
metadata: unknown;
|
||||
created_at_ms: number;
|
||||
byte_count: number;
|
||||
encoding: "utf8" | "base64";
|
||||
data: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const packagedDesktop = "__TAURI_INTERNALS__" in window
|
||||
|| window.location.protocol === "tauri:"
|
||||
|| window.location.hostname === "tauri.localhost";
|
||||
const API_ROOT = "/__byok-api__/api";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_ROOT}${path}`, {
|
||||
...init,
|
||||
headers: init?.body ? { "content-type": "application/json", ...init.headers } : init?.headers,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new Error(t("无法连接本地管理服务"), { cause });
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
ads: (disabledAdIds: Iterable<string>) => {
|
||||
const value = [...disabledAdIds].join(",");
|
||||
return request<AdRuntime>("/ads", value ? { headers: { "disable-ad-ids": value } } : undefined);
|
||||
},
|
||||
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
|
||||
providers: () => request<Provider[]>("/providers"),
|
||||
createProvider: (input: ProviderInput) => request<Provider>("/providers", { method: "POST", body: JSON.stringify(input) }),
|
||||
updateProvider: (id: number, input: ProviderInput) => request<Provider>(`/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
||||
deleteProvider: (id: number) => request<void>(`/providers/${id}`, { method: "DELETE" }),
|
||||
discoverModels: (id: number) => request<{ models: string[] }>(`/providers/${id}/models/discover`, { method: "POST" }),
|
||||
saveModels: (id: number, models: ModelInput[]) => request<Model[]>(`/providers/${id}/models`, { method: "POST", body: JSON.stringify({ models }) }),
|
||||
models: () => request<Model[]>("/models"),
|
||||
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
|
||||
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[]; providerIds?: number[] }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter) {
|
||||
params.set("start_ms", String(filter.startMs));
|
||||
params.set("end_ms", String(filter.endMs));
|
||||
if (filter.modelHashes?.length) params.set("model_hashes", JSON.stringify(filter.modelHashes));
|
||||
if (filter.providerIds?.length) params.set("provider_ids", JSON.stringify(filter.providerIds));
|
||||
}
|
||||
const query = params.size ? `?${params}` : "";
|
||||
return request<Overview>(`/overview${query}`);
|
||||
},
|
||||
createCursorModels: (provider: ProviderSelection, models: ModelInput[]) => request<{ provider: Provider; models: Model[] }>("/harness/cursor/models", { method: "POST", body: JSON.stringify({ provider, models }) }),
|
||||
discoverCursorModels: (provider: ProviderSelection) => request<{ models: string[] }>("/harness/cursor/models/discover", { method: "POST", body: JSON.stringify({ provider }) }),
|
||||
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||
openCursorCaInstallTerminal: async (command: string) => {
|
||||
if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA"));
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("open_terminal_with_command", { command });
|
||||
},
|
||||
copyCursorText: async (text: string) => {
|
||||
if (!packagedDesktop) throw new Error(t("请在桌面应用中复制到系统剪贴板"));
|
||||
const { writeText } = await import("@tauri-apps/plugin-clipboard-manager");
|
||||
await writeText(text);
|
||||
},
|
||||
setCursorEnabled: (enabled: boolean) => request<CursorHarnessStatus>("/harness/cursor/enabled", { method: "PUT", body: JSON.stringify({ enabled }) }),
|
||||
calls: () => request<LlmCall[]>("/llm-calls?limit=200"),
|
||||
call: (id: string) => request<CallDetail>(`/llm-calls/${encodeURIComponent(id)}`),
|
||||
openCallDetails: async (id: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = `/calls/${encodeURIComponent(id)}`;
|
||||
await request<void>("/desktop/open-external-url", { method: "POST", body: JSON.stringify({ url: url.toString() }) });
|
||||
},
|
||||
openExternalUrl: (url: string) => request<void>("/desktop/open-external-url", { method: "POST", body: JSON.stringify({ url }) }),
|
||||
observability: () => request<{ detailed: boolean }>("/settings/observability"),
|
||||
setObservability: (detailed: boolean) => request<{ detailed: boolean }>("/settings/observability", { method: "PUT", body: JSON.stringify({ detailed }) }),
|
||||
ports: () => request<PortSettings>("/settings/ports"),
|
||||
setPorts: (settings: PortSettings) => request<PortSettings>("/settings/ports", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
statisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics"),
|
||||
clearStatisticsStorage: () => request<StatisticsStorage>("/settings/storage/statistics", { method: "DELETE" }),
|
||||
proxySettings: () => request<ProxySettings>("/settings/proxy"),
|
||||
setProxySettings: (settings: ProxySettingsInput) => request<ProxySettings>("/settings/proxy", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg width="310" height="310" viewBox="0 0 310 310" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Cursor</title>
|
||||
<defs>
|
||||
<linearGradient x1="12.6410966%" y1="0%" x2="93.9769479%" y2="100%" id="cursor-border-gradient">
|
||||
<stop stop-color="#C8C8C8" offset="0%"/>
|
||||
<stop stop-color="#000000" offset="45.1598984%"/>
|
||||
<stop stop-color="#979797" offset="100%"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g stroke="none" fill="none" fill-rule="evenodd" transform="translate(21 21)">
|
||||
<path stroke="url(#cursor-border-gradient)" stroke-width="3" stroke-linejoin="square" fill="#000000" d="M206.569725 2.38059035c9.666379.78977439 16.750829 2.27231033 22.749786 4.86175573l2.508886 1.17873983c11.948293 6.08795909 21.662558 15.80222449 27.750517 27.75051739c3.330466 6.5364062 5.137896 14.2113817 6.040496 25.2586721c.748007 9.1551756.88059 17.5905616.88059 42.5697246v60c0 24.979163-.132583 33.414549-.88059 42.569725c-.9026 11.04729-2.71003 18.722265-6.040496 25.258672c-6.087959 11.948293-15.802224 21.662558-27.750517 27.750517c-6.536407 3.330466-14.211382 5.137896-25.258672 6.040496c-9.155176.748007-17.590562.88059-42.569725.88059h-60c-24.979163 0-33.414549-.132583-42.569725-.88059c-11.04729-.9026-18.722265-2.71003-25.258672-6.040496c-11.948293-6.087959-21.662558-15.802224-27.750517-27.750517c-3.330465-6.536407-5.137896-14.211382-6.040496-25.258672C1.632583 197.414549 1.5 188.979163 1.5 164v-60c0-24.979163.132583-33.414549.88059-42.569725c.9026-11.0472904 2.71003-18.7222659 6.040496-25.2586721C14.509045 24.2233104 24.22331 14.509045 36.171603 8.42108571c6.536407-3.33046534 14.211382-5.13789606 25.258672-6.04049536C70.585451 1.63258295 79.020837 1.5 104 1.5h60c24.979163 0 33.414549.13258295 42.569725.88059035Z"/>
|
||||
<g transform="translate(24.9831 26.1186)">
|
||||
<path d="M0 0h216.898305v216.898305H0z"/>
|
||||
<path fill="#444444" fill-rule="nonzero" d="M26.0277966 61.6955179v94.4712621H192.316497V61.6955179L109.172147 14.459887"/>
|
||||
<path fill="#939393" fill-rule="nonzero" d="M109.172147 109.172147V14.459887L26.0277966 61.8160169L192.316497 156.528277l-83.14435 47.35613l-83.1443504-47.35613"/>
|
||||
<path fill="#E3E3E3" fill-rule="nonzero" d="M190.870508 60.7315254h-82.421355V202.438418"/>
|
||||
<path fill="#FFFFFF" fill-rule="nonzero" d="M26.0277966 60.7315254H192.316497l-83.14435 47.7176276"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,38 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
section { min-width: 0; }
|
||||
h4 { margin: 12px 0 6px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; font-weight: 500; }
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-sideBar-border);
|
||||
border-radius: 6px;
|
||||
|
||||
> div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 132px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--vscode-editorWidget-background);
|
||||
}
|
||||
|
||||
dt { color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
|
||||
dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
||||
code { white-space: pre-wrap; font-family: var(--oa-code-font); font-size: type.$font-size-xs; }
|
||||
}
|
||||
.meta{
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.meta,
|
||||
.empty {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { CallDetail } from "../api";
|
||||
import { JsonEditor } from "./ui/JsonEditor";
|
||||
import { Tabs, type TabItem } from "./ui/Tabs";
|
||||
import styles from "./CallDetails.module.scss";
|
||||
|
||||
const show = (value: string | number | null) => value ?? "-";
|
||||
const timing = (value: number | null) => value == null ? "-" : `${value} ms`;
|
||||
|
||||
export function CallDetails({ detail }: { detail: CallDetail }) {
|
||||
const { call, request, response_chunks: chunks, cursor_trace: cursorTrace } = detail;
|
||||
const responseBody = chunks.map((chunk) => chunk.data).join("");
|
||||
const responseBytes = chunks.reduce((total, chunk) => total + chunk.byte_count, 0);
|
||||
const fields: Array<[string, string | number]> = [
|
||||
["Call ID", call.call_id],
|
||||
[t("调用类型"), call.call_kind === "cursor_official" ? t("Cursor 官方") : "LLM"],
|
||||
[t("路由"), call.route === "cursor_official" ? t("Cursor 官方") : "BYOK"],
|
||||
["Run ID", call.run_id],
|
||||
["Conversation ID", call.conversation_id],
|
||||
[t("上游调用序号"), call.provider_call_index],
|
||||
["Model Hash", show(call.model_hash)],
|
||||
[t("上游类型"), call.provider_type],
|
||||
[t("上游地址"), call.provider_url],
|
||||
[t("最终请求类型"), call.request_type],
|
||||
[t("最终请求地址"), call.request_url],
|
||||
["Model ID", call.model_id],
|
||||
[t("显示名称"), call.display_name],
|
||||
[t("思考强度"), show(call.reasoning_effort)],
|
||||
["Fast", call.fast == null ? "-" : call.fast ? t("是") : t("否")],
|
||||
[t("状态"), call.status],
|
||||
["Finish Reason", show(call.finish_reason)],
|
||||
["HTTP Status", show(call.http_status)],
|
||||
["Created At", `${call.created_at_ms} · ${new Date(call.created_at_ms).toLocaleString()}`],
|
||||
[t("耗时"), timing(call.duration_ms)],
|
||||
["TTFB", timing(call.ttfb_ms)],
|
||||
["TTFT", timing(call.ttft_ms)],
|
||||
["Input Token", show(call.input_tokens)],
|
||||
["Output Token", show(call.output_tokens)],
|
||||
["Total Token", show(call.total_tokens)],
|
||||
["Cache Read Token", show(call.cache_read_tokens)],
|
||||
["Cache Write Token", show(call.cache_write_tokens)],
|
||||
["Reasoning Token", show(call.reasoning_tokens)],
|
||||
[t("消息数"), call.message_count],
|
||||
[t("工具数"), call.tool_count],
|
||||
[t("详细记录"), call.detailed ? t("是") : t("否")],
|
||||
["Error Kind", show(call.error_kind)],
|
||||
["Error Message", show(call.error_message)],
|
||||
];
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ value: "call", label: t("调用信息"), content: <section>
|
||||
<dl className={styles.details}>{fields.map(([label, value]) => <div key={label}><dt>{label}</dt><dd><code>{value}</code></dd></div>)}</dl>
|
||||
</section> },
|
||||
{ value: "request", label: t("请求"), content: <section>
|
||||
{request ? <>
|
||||
<div className={styles.meta}>{t("字节数")}:{request.byte_count}</div>
|
||||
<h4>{t("请求头")}</h4><JsonEditor ariaLabel={t("请求头")} value={JSON.stringify(request.headers)} readOnly />
|
||||
<h4>{t("请求体")}</h4><JsonEditor ariaLabel={t("请求体")} value={JSON.stringify(request.body)} readOnly detail />
|
||||
</> : <div className={styles.empty}>{t("未记录请求内容,请开启详细记录后重试。")}</div>}
|
||||
</section> },
|
||||
{ value: "response", label: t("响应流"), content: <section>
|
||||
{chunks.length > 0 ? <>
|
||||
<div className={styles.meta}>{t("分块数")}:{chunks.length} · {t("字节数")}:{responseBytes}</div>
|
||||
<JsonEditor ariaLabel={t("响应流")} value={responseBody} readOnly detail />
|
||||
</> : <div className={styles.empty}>{t("未记录响应内容,请开启详细记录后重试。")}</div>}
|
||||
</section> },
|
||||
];
|
||||
|
||||
if (cursorTrace) tabs.push({ value: "cursor-trace", label: t("Cursor 追踪"), content: <section>
|
||||
<div className={styles.meta}>
|
||||
Request ID:{cursorTrace.trace.request_id} · {t("工件数")}:{cursorTrace.artifacts.length}
|
||||
</div>
|
||||
<JsonEditor
|
||||
ariaLabel={t("Cursor 追踪")}
|
||||
value={JSON.stringify({ trace: cursorTrace.trace, artifacts: cursorTrace.artifacts })}
|
||||
readOnly
|
||||
detail
|
||||
/>
|
||||
</section> });
|
||||
|
||||
return <div className={styles.root}><Tabs items={tabs} /></div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.status {
|
||||
padding: 3px 7px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-input-background);
|
||||
border-radius: 99px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.completed { color: #56b870; }
|
||||
.failed { color: var(--vscode-errorForeground, #f48771); }
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { LlmCall } from "../api";
|
||||
import controls from "./ui/Controls.module.scss";
|
||||
import { DataTable, type DataTableColumn } from "./ui/DataTable";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { TooltipTrigger } from "./ui/TooltipTrigger";
|
||||
import { eyeIcon } from "./ui/icons";
|
||||
import styles from "./CallTable.module.scss";
|
||||
|
||||
const value = (input: string | number | null) => input ?? "-";
|
||||
const milliseconds = (input: number | null) => input == null ? "-" : `${input} ms`;
|
||||
|
||||
export function CallTable({ calls, onDetails }: { calls: LlmCall[]; onDetails: (call: LlmCall) => void }) {
|
||||
const columns: DataTableColumn<LlmCall>[] = [
|
||||
{
|
||||
key: "status",
|
||||
header: t("状态"),
|
||||
render: (call) => (
|
||||
<span
|
||||
className={[styles.status, styles[call.status]]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
key: "display_name",
|
||||
header: t("显示名称"),
|
||||
render: (call) => call.display_name,
|
||||
title: (call) => call.display_name,
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: t("时间"),
|
||||
render: (call) => new Date(call.created_at_ms).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: "model_id",
|
||||
header: t("模型名称"),
|
||||
render: (call) => call.model_id,
|
||||
title: (call) => call.model_id,
|
||||
},
|
||||
{
|
||||
key: "reasoning_effort",
|
||||
header: t("思考强度"),
|
||||
render: (call) => value(call.reasoning_effort),
|
||||
},
|
||||
{
|
||||
key: "fast",
|
||||
header: "Fast",
|
||||
render: (call) => call.fast == null ? "-" : call.fast ? t("是") : t("否"),
|
||||
},
|
||||
|
||||
{
|
||||
key: "call_kind",
|
||||
header: t("调用类型"),
|
||||
render: (call) =>
|
||||
call.call_kind === "cursor_official" ? t("Cursor 官方") : "LLM",
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
header: t("路由"),
|
||||
render: (call) =>
|
||||
call.route === "cursor_official" ? t("Cursor 官方") : "BYOK",
|
||||
},
|
||||
|
||||
// { key: "model_hash", header: "Model Hash", render: (call) => value(call.model_hash), title: (call) => call.model_hash ?? undefined },
|
||||
// { key: "provider_type", header: t("供应商类型"), render: (call) => call.provider_type },
|
||||
// { key: "provider_url", header: t("供应商地址"), render: (call) => call.provider_url, title: (call) => call.provider_url },
|
||||
// { key: "request_type", header: t("最终请求类型"), render: (call) => call.request_type },
|
||||
// { key: "request_url", header: t("最终请求地址"), render: (call) => call.request_url, title: (call) => call.request_url },
|
||||
{
|
||||
key: "finish_reason",
|
||||
header: "Finish Reason",
|
||||
render: (call) => value(call.finish_reason),
|
||||
},
|
||||
{ key: "http", header: "HTTP", render: (call) => value(call.http_status) },
|
||||
{
|
||||
key: "duration",
|
||||
header: t("耗时"),
|
||||
render: (call) => milliseconds(call.duration_ms),
|
||||
},
|
||||
{
|
||||
key: "ttfb",
|
||||
header: "TTFB",
|
||||
render: (call) => milliseconds(call.ttfb_ms),
|
||||
},
|
||||
{
|
||||
key: "ttft",
|
||||
header: "TTFT",
|
||||
render: (call) => milliseconds(call.ttft_ms),
|
||||
},
|
||||
{
|
||||
key: "input_tokens",
|
||||
header: "Input Token",
|
||||
render: (call) => value(call.input_tokens),
|
||||
},
|
||||
{
|
||||
key: "output_tokens",
|
||||
header: "Output Token",
|
||||
render: (call) => value(call.output_tokens),
|
||||
},
|
||||
{
|
||||
key: "total_tokens",
|
||||
header: "Total Token",
|
||||
render: (call) => value(call.total_tokens),
|
||||
},
|
||||
{
|
||||
key: "cache_read",
|
||||
header: "Cache Read",
|
||||
render: (call) => value(call.cache_read_tokens),
|
||||
},
|
||||
{
|
||||
key: "cache_write",
|
||||
header: "Cache Write",
|
||||
render: (call) => value(call.cache_write_tokens),
|
||||
},
|
||||
{
|
||||
key: "reasoning_tokens",
|
||||
header: "Reasoning Token",
|
||||
render: (call) => value(call.reasoning_tokens),
|
||||
},
|
||||
{
|
||||
key: "message_count",
|
||||
header: t("消息数"),
|
||||
render: (call) => call.message_count,
|
||||
},
|
||||
{
|
||||
key: "tool_count",
|
||||
header: t("工具数"),
|
||||
render: (call) => call.tool_count,
|
||||
},
|
||||
{
|
||||
key: "detailed",
|
||||
header: t("详细记录"),
|
||||
render: (call) => (call.detailed ? t("是") : t("否")),
|
||||
},
|
||||
|
||||
{
|
||||
key: "error_kind",
|
||||
header: "Error Kind",
|
||||
render: (call) => value(call.error_kind),
|
||||
title: (call) => call.error_kind ?? undefined,
|
||||
},
|
||||
{
|
||||
key: "error_message",
|
||||
header: "Error Message",
|
||||
render: (call) => value(call.error_message),
|
||||
title: (call) => call.error_message ?? undefined,
|
||||
},
|
||||
{
|
||||
key: "call_id",
|
||||
header: "Call ID",
|
||||
render: (call) => call.call_id,
|
||||
title: (call) => call.call_id,
|
||||
},
|
||||
{
|
||||
key: "run_id",
|
||||
header: "Run ID",
|
||||
render: (call) => call.run_id,
|
||||
title: (call) => call.run_id,
|
||||
},
|
||||
{
|
||||
key: "conversation_id",
|
||||
header: "Conversation ID",
|
||||
render: (call) => call.conversation_id,
|
||||
title: (call) => call.conversation_id,
|
||||
},
|
||||
{
|
||||
key: "call_index",
|
||||
header: "Call Index",
|
||||
render: (call) => call.provider_call_index,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: t("操作"),
|
||||
sticky: "right",
|
||||
render: (call) => (
|
||||
<TooltipTrigger label={t("查看详情")}>
|
||||
<button
|
||||
className={controls.iconButton}
|
||||
aria-label={t("查看详情")}
|
||||
onClick={() => onDetails(call)}
|
||||
>
|
||||
<Icon icon={eyeIcon} size="1.1em" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
),
|
||||
},
|
||||
];
|
||||
return <DataTable rows={calls} columns={columns} rowKey={(call) => call.call_id} minWidth={4180} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.form { grid-template-columns: 1fr; }
|
||||
.fullWidth { grid-column: auto; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ProviderInput, ProviderType } from "../api";
|
||||
import { FormField, TextInput } from "./ui/FormControls";
|
||||
import { JsonEditor } from "./ui/JsonEditor";
|
||||
import { Select } from "./ui/Select";
|
||||
import { claudeIcon, openAiIcon } from "./ui/icons";
|
||||
import styles from "./ProviderEditor.module.scss";
|
||||
|
||||
export function ProviderEditor({ value, headersText, extraText, editing, onChange, onHeadersChange, onExtraChange }: {
|
||||
value: ProviderInput;
|
||||
headersText: string;
|
||||
extraText: string;
|
||||
editing: boolean;
|
||||
onChange: (value: ProviderInput) => void;
|
||||
onHeadersChange: (value: string) => void;
|
||||
onExtraChange: (value: string) => void;
|
||||
}) {
|
||||
const patch = (next: Partial<ProviderInput>) => onChange({ ...value, ...next });
|
||||
return <div className={styles.form}>
|
||||
<FormField className={styles.fullWidth} label={t("名称")}><TextInput placeholder={t("例如:OpenAI")} value={value.name} onChange={(event) => patch({ name: event.target.value })} /></FormField>
|
||||
<FormField label={t("协议")} hint={t("选择上游服务使用的请求协议。")}><Select ariaLabel={t("协议")} value={value.provider_type} options={[
|
||||
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon },
|
||||
{ value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon },
|
||||
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
|
||||
<FormField label="Base URL" hint={t("模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。")}><TextInput placeholder="https://api.example.com/v1" value={value.base_url} onChange={(event) => patch({ base_url: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><TextInput type="password" autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={headersText} onChange={onHeadersChange} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.badge {
|
||||
padding: 3px 7px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-input-background);
|
||||
border-radius: 99px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Provider } from "../api";
|
||||
import controls from "./ui/Controls.module.scss";
|
||||
import { DataTable, type DataTableColumn } from "./ui/DataTable";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { TooltipTrigger } from "./ui/TooltipTrigger";
|
||||
import { editIcon, trashIcon } from "./ui/icons";
|
||||
import styles from "./ProviderTable.module.scss";
|
||||
|
||||
const json = (value: unknown) => JSON.stringify(value);
|
||||
|
||||
export function ProviderTable({ providers, onEdit, onDelete }: {
|
||||
providers: Provider[];
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
}) {
|
||||
const columns: DataTableColumn<Provider>[] = [
|
||||
{ key: "name", header: t("名称"), render: (provider) => provider.name, title: (provider) => provider.name },
|
||||
{ key: "type", header: t("协议"), render: (provider) => provider.provider_type },
|
||||
{ key: "url", header: "Base URL", render: (provider) => provider.base_url, title: (provider) => provider.base_url },
|
||||
{ key: "key", header: "API Key", render: (provider) => <span className={styles.badge}>{provider.has_api_key ? t("已配置") : t("未配置")}</span> },
|
||||
{ key: "headers", header: "Headers JSON", render: (provider) => json(provider.custom_headers), title: (provider) => json(provider.custom_headers) },
|
||||
{ key: "extra", header: t("额外参数 JSON"), render: (provider) => json(provider.extra_params), title: (provider) => json(provider.extra_params) },
|
||||
{ key: "created", header: t("创建时间"), render: (provider) => new Date(provider.created_at_ms).toLocaleString() },
|
||||
{ key: "updated", header: t("更新时间"), render: (provider) => new Date(provider.updated_at_ms).toLocaleString() },
|
||||
{ key: "actions", header: t("操作"), sticky: "right", render: (provider) => <div className={styles.actions}>
|
||||
<TooltipTrigger label={t("编辑上游")}><button className={controls.iconButton} aria-label={t("编辑上游")} onClick={() => onEdit(provider)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("删除上游")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除上游")} onClick={() => onDelete(provider)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</div> },
|
||||
];
|
||||
return <DataTable rows={providers} columns={columns} rowKey={(provider) => provider.provider_id} minWidth={1400} />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { RefObject } from "react";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { windowCloseIcon } from "../ui/icons";
|
||||
import type { AdSlot } from "./types";
|
||||
import styles from "./Ads.module.scss";
|
||||
|
||||
type AdMenuProps = {
|
||||
ads: AdSlot[];
|
||||
activeAdId?: string;
|
||||
dismissingAdId?: string;
|
||||
readAdIds: ReadonlySet<string>;
|
||||
triggerRefs: RefObject<Map<string, HTMLButtonElement>>;
|
||||
onOpen: (ad: AdSlot) => void;
|
||||
onDismiss: (ad: AdSlot) => void;
|
||||
};
|
||||
|
||||
export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs, onOpen, onDismiss }: AdMenuProps) {
|
||||
return <div className={styles.menuSlots} aria-label={t("推荐内容")}>
|
||||
{ads.map((ad) => <div key={ad.id} className={styles.menuSlotContainer}>
|
||||
<button
|
||||
ref={(node) => {
|
||||
if (node) triggerRefs.current.set(ad.id, node);
|
||||
else triggerRefs.current.delete(ad.id);
|
||||
}}
|
||||
type="button"
|
||||
className={styles.menuSlot}
|
||||
title={`${ad.target.title}\n${ad.target.description}`}
|
||||
aria-label={`${ad.target.title},${ad.target.description}${readAdIds.has(ad.id) ? "" : `,${t("未读")}`}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={activeAdId === ad.id}
|
||||
aria-controls={activeAdId === ad.id ? "menu-ad-dialog" : undefined}
|
||||
onClick={() => onOpen(ad)}
|
||||
>
|
||||
{!readAdIds.has(ad.id) && <span className={styles.unreadDot} aria-hidden="true" />}
|
||||
<img className={styles.menuImage} src={ad.target.imageUrl} alt="" />
|
||||
<span className={styles.menuCopy}>
|
||||
<span className={styles.menuTitle}>{ad.target.title}</span>
|
||||
<span className={styles.menuSubtitle}>{ad.target.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuDismiss}
|
||||
aria-label={`${t("不再显示广告")}:${ad.target.title}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={dismissingAdId === ad.id}
|
||||
aria-controls={dismissingAdId === ad.id ? "dismiss-ad-dialog" : undefined}
|
||||
onClick={() => onDismiss(ad)}
|
||||
>
|
||||
<Icon icon={windowCloseIcon} size="1em" />
|
||||
</button>
|
||||
</div>)}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.menuSlots {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.menuSlotContainer {
|
||||
position: relative;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.menuSlot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 32px 0 8px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.menuDismiss {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: color-mix(in srgb, var(--vscode-foreground) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.unreadDot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 25px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--vscode-errorForeground);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px var(--vscode-editorWidget-background);
|
||||
}
|
||||
|
||||
.menuImage {
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menuCopy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.menuTitle,
|
||||
.menuSubtitle {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menuTitle {
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.menuSubtitle {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
|
||||
.floatingAd {
|
||||
position: fixed;
|
||||
z-index: 20000;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: min(360px, calc(100vh - 32px));
|
||||
max-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border-radius: 15px;
|
||||
border: none;
|
||||
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
|
||||
animation: ad-enter 180ms ease-out;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex: 0 0 112px;
|
||||
position: relative;
|
||||
height: 112px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.promotionLabel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
padding: 3px 7px;
|
||||
color: white;
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1;
|
||||
background: rgb(0 0 0 / 48%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: type.$font-size-xl;
|
||||
line-height: 1;
|
||||
background: rgb(0 0 0 / 16%);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
background: rgb(0 0 0 / 30%);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
--scroll-shadow-bottom-size: 16px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bodyContent {
|
||||
padding: 14px 16px 18px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.detail {
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
color: #202020;
|
||||
background: #f2f2f2;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ad-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.floatingAd {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { windowCloseIcon } from "../ui/icons";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import type { AdAction, AdSlot } from "./types";
|
||||
import styles from "./Ads.module.scss";
|
||||
|
||||
type AdTextSection =
|
||||
| { key: "intro"; kind: "intro" }
|
||||
| { key: string; kind: "detail"; label: string; value: string };
|
||||
|
||||
type FloatingAdProps = {
|
||||
ad: AdSlot;
|
||||
trigger: HTMLButtonElement | null;
|
||||
onClose: () => void;
|
||||
onAction: (action: AdAction) => void;
|
||||
};
|
||||
|
||||
export function FloatingAd({ ad, trigger, onClose, onAction }: FloatingAdProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const textSections = useMemo<AdTextSection[]>(() => [
|
||||
{ key: "intro", kind: "intro" },
|
||||
...ad.content.details.map((detail, index) => ({
|
||||
key: `detail-${index}-${detail.label}`,
|
||||
kind: "detail" as const,
|
||||
label: detail.label,
|
||||
value: detail.value,
|
||||
})),
|
||||
], [ad.content.details]);
|
||||
|
||||
useEffect(() => {
|
||||
dialog.current?.focus();
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!dialog.current?.contains(target) && !trigger?.contains(target)) onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
trigger?.focus();
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose, trigger]);
|
||||
|
||||
return createPortal(<div
|
||||
id="menu-ad-dialog"
|
||||
ref={dialog}
|
||||
className={styles.floatingAd}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label={ad.content.title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className={styles.hero}>
|
||||
<img src={ad.content.imageUrl} alt="" />
|
||||
<span className={styles.promotionLabel}>{t("推广")}</span>
|
||||
<button type="button" className={styles.close} aria-label={t("关闭广告")} onClick={() => { onClose(); trigger?.focus(); }}>
|
||||
<Icon icon={windowCloseIcon} size="1.1em" />
|
||||
</button>
|
||||
</div>
|
||||
<VirtualList
|
||||
items={textSections}
|
||||
itemKey="key"
|
||||
estimatedItemHeight={58}
|
||||
itemGap={12}
|
||||
className={`${styles.body} scroll-shadow-bottom`}
|
||||
contentClassName={styles.bodyContent}
|
||||
>
|
||||
{(section) => section.kind === "intro"
|
||||
? <div className={styles.intro}>
|
||||
<h2>{ad.content.title}</h2>
|
||||
<p>{ad.content.description}</p>
|
||||
</div>
|
||||
: <div className={styles.detail}>
|
||||
<h3>{section.label}</h3>
|
||||
<p>{section.value}</p>
|
||||
</div>}
|
||||
</VirtualList>
|
||||
<div className={styles.footer}>
|
||||
<button type="button" onClick={() => { onClose(); onAction(ad.content.button.action); }}>{ad.content.button.label}</button>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const AdActionType = {
|
||||
OpenBrowser: "open_browser",
|
||||
} as const;
|
||||
|
||||
export type AdAction = {
|
||||
type: typeof AdActionType.OpenBrowser;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type AdSlot = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
placement: "menu";
|
||||
target: {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
content: {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
details: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
button: {
|
||||
label: string;
|
||||
action: AdAction;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type AdRuntime = {
|
||||
slots: AdSlot[];
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.axis {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
margin-top: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 28px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { init, Rect, type ElementEvent } from "zrender";
|
||||
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
|
||||
import styles from "./ContributionCalendarChart.module.scss";
|
||||
|
||||
export type ContributionDay = {
|
||||
date: string;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
type ContributionCalendarChartProps = {
|
||||
data: ContributionDay[];
|
||||
};
|
||||
|
||||
type CalendarCell = ContributionDay & {
|
||||
column: number;
|
||||
row: number;
|
||||
level: number;
|
||||
};
|
||||
|
||||
type CellExtra = CalendarCell & {
|
||||
kind: "calendar-cell";
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type TooltipState = {
|
||||
date: string;
|
||||
tokens: number;
|
||||
anchor: TooltipAnchor;
|
||||
};
|
||||
|
||||
type AxisLabel = {
|
||||
key: string;
|
||||
text: string;
|
||||
left: number;
|
||||
};
|
||||
|
||||
const levelColors = [
|
||||
"rgba(139, 148, 158, 0.20)",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
];
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const CALENDAR_CONFIG = {
|
||||
cellAspectRatio: 0.9,
|
||||
cellGap: 3,
|
||||
resizeTransitionMs: 180,
|
||||
rowCount: 7,
|
||||
axisLabelGap: 8,
|
||||
axisLabelWidth: 28,
|
||||
} as const;
|
||||
const tokenFormatter = new Intl.NumberFormat("zh-CN");
|
||||
|
||||
function parseDate(date: string) {
|
||||
return new Date(`${date}T00:00:00Z`);
|
||||
}
|
||||
|
||||
function mondayIndex(date: Date) {
|
||||
return (date.getUTCDay() + 6) % 7;
|
||||
}
|
||||
|
||||
function cellOffset(index: number, cellSize: number) {
|
||||
return index * (cellSize + CALENDAR_CONFIG.cellGap);
|
||||
}
|
||||
|
||||
function isCellExtra(value: unknown): value is CellExtra {
|
||||
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
|
||||
}
|
||||
|
||||
function buildCalendarLayout(data: ContributionDay[]) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
|
||||
const firstDate = parseDate(data[0].date);
|
||||
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
|
||||
const cells: CalendarCell[] = data.map((day) => {
|
||||
const date = parseDate(day.date);
|
||||
const daysFromStart = Math.round((date.getTime() - calendarStart.getTime()) / DAY_IN_MS);
|
||||
const level = day.tokens === 0 ? 0 : Math.max(1, Math.ceil((day.tokens / maximum) * 4));
|
||||
return { ...day, column: Math.floor(daysFromStart / 7), row: mondayIndex(date), level };
|
||||
});
|
||||
const columnCount = cells.at(-1)!.column + 1;
|
||||
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
|
||||
const date = parseDate(cell.date);
|
||||
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
|
||||
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: `${date.getUTCMonth() + 1}月`, column: cell.column });
|
||||
return ticks;
|
||||
}, []);
|
||||
return { cells, columnCount, monthTicks };
|
||||
}
|
||||
|
||||
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
|
||||
const scheduleDrawRef = useRef<() => void>(() => undefined);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
|
||||
const layout = useMemo(() => buildCalendarLayout(data), [data]);
|
||||
layoutRef.current = layout;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
const node = canvasRef.current;
|
||||
if (!scroller || !node) return;
|
||||
|
||||
const chart = init(node, {
|
||||
renderer: "canvas",
|
||||
width: 1,
|
||||
height: 1,
|
||||
useDirtyRect: true,
|
||||
});
|
||||
|
||||
const handleMouseOver = (event: ElementEvent) => {
|
||||
const extra = event.target?.extra;
|
||||
if (!isCellExtra(extra)) return;
|
||||
const anchor: TooltipAnchor = {
|
||||
contextElement: node,
|
||||
getBoundingClientRect: () => {
|
||||
const bounds = node.getBoundingClientRect();
|
||||
return new DOMRect(bounds.left + extra.x, bounds.top + extra.y, extra.width, extra.height);
|
||||
},
|
||||
};
|
||||
setTooltip({ date: extra.date, tokens: extra.tokens, anchor });
|
||||
};
|
||||
const handleMouseOut = (event: ElementEvent) => {
|
||||
if (isCellExtra(event.target?.extra)) setTooltip(null);
|
||||
};
|
||||
|
||||
chart.on("mouseover", handleMouseOver);
|
||||
chart.on("mouseout", handleMouseOut);
|
||||
|
||||
const cellRects = new Map<string, Rect>();
|
||||
let drawFrame = 0;
|
||||
let lastAvailableWidth = -1;
|
||||
let lastCanvasHeight = -1;
|
||||
let lastLayout: typeof layout = null;
|
||||
const draw = () => {
|
||||
const currentLayout = layoutRef.current;
|
||||
if (!currentLayout) return;
|
||||
const availableWidth = Math.floor(scroller.getBoundingClientRect().width);
|
||||
if (availableWidth <= 0 || (availableWidth === lastAvailableWidth && currentLayout === lastLayout)) return;
|
||||
const gapsWidth = (currentLayout.columnCount - 1) * CALENDAR_CONFIG.cellGap;
|
||||
const cellWidth = Math.max(0, (availableWidth - gapsWidth) / currentLayout.columnCount);
|
||||
const cellHeight = cellWidth / CALENDAR_CONFIG.cellAspectRatio;
|
||||
const width = availableWidth;
|
||||
const height = CALENDAR_CONFIG.rowCount * cellHeight
|
||||
+ (CALENDAR_CONFIG.rowCount - 1) * CALENDAR_CONFIG.cellGap;
|
||||
|
||||
let lastLabelEnd = -Infinity;
|
||||
const nextAxisLabels = currentLayout.monthTicks.flatMap((tick) => {
|
||||
const left = Math.min(
|
||||
cellOffset(tick.column, cellWidth),
|
||||
availableWidth - CALENDAR_CONFIG.axisLabelWidth,
|
||||
);
|
||||
if (left < lastLabelEnd + CALENDAR_CONFIG.axisLabelGap) return [];
|
||||
lastLabelEnd = left + CALENDAR_CONFIG.axisLabelWidth;
|
||||
return [{ ...tick, left }];
|
||||
});
|
||||
setAxisLabels(nextAxisLabels);
|
||||
|
||||
if (availableWidth !== lastAvailableWidth || height !== lastCanvasHeight) {
|
||||
node.style.width = "100%";
|
||||
node.style.height = `${height}px`;
|
||||
chart.resize({ width, height });
|
||||
lastAvailableWidth = availableWidth;
|
||||
lastCanvasHeight = height;
|
||||
}
|
||||
lastLayout = currentLayout;
|
||||
|
||||
const currentDates = new Set(currentLayout.cells.map((cell) => cell.date));
|
||||
for (const [date, rect] of cellRects) {
|
||||
if (currentDates.has(date)) continue;
|
||||
chart.remove(rect);
|
||||
cellRects.delete(date);
|
||||
}
|
||||
|
||||
for (const cell of currentLayout.cells) {
|
||||
const x = cellOffset(cell.column, cellWidth);
|
||||
const y = cellOffset(cell.row, cellHeight);
|
||||
const shape = {
|
||||
x,
|
||||
y,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
r: Math.min(3, Math.min(cellWidth, cellHeight) / 4),
|
||||
};
|
||||
const extra = {
|
||||
...cell,
|
||||
kind: "calendar-cell" as const,
|
||||
x,
|
||||
y,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
} satisfies CellExtra;
|
||||
const current = cellRects.get(cell.date);
|
||||
|
||||
if (current) {
|
||||
current.extra = extra;
|
||||
current.stopAnimation();
|
||||
current.animateTo(
|
||||
{ shape, style: { fill: levelColors[cell.level] } },
|
||||
{ duration: CALENDAR_CONFIG.resizeTransitionMs, easing: "cubicOut" },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = new Rect({
|
||||
shape,
|
||||
style: {
|
||||
fill: levelColors[cell.level],
|
||||
stroke: "rgba(139, 148, 158, 0.10)",
|
||||
lineWidth: 1,
|
||||
},
|
||||
cursor: "default",
|
||||
extra,
|
||||
});
|
||||
cellRects.set(cell.date, rect);
|
||||
chart.add(rect);
|
||||
}
|
||||
setTooltip(null);
|
||||
};
|
||||
const scheduleDraw = () => {
|
||||
window.cancelAnimationFrame(drawFrame);
|
||||
drawFrame = window.requestAnimationFrame(draw);
|
||||
};
|
||||
scheduleDrawRef.current = scheduleDraw;
|
||||
const observer = new ResizeObserver(scheduleDraw);
|
||||
observer.observe(scroller);
|
||||
scheduleDraw();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.cancelAnimationFrame(drawFrame);
|
||||
scheduleDrawRef.current = () => undefined;
|
||||
chart.dispose();
|
||||
};
|
||||
}, [layout !== null]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scheduleDrawRef.current();
|
||||
}, [layout]);
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
return (
|
||||
<section className={styles.root} aria-label={t("过去一年的 Token 用量")}>
|
||||
<div ref={scrollerRef} className={styles.scroller}>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={styles.canvas}
|
||||
role="img"
|
||||
aria-label={t("过去一年的 Token 用量日历")}
|
||||
/>
|
||||
<div className={styles.axis} aria-hidden="true">
|
||||
{axisLabels.map((label) => <span key={label.key} style={{ left: label.left }}>{label.text}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip anchor={tooltip?.anchor ?? null}>
|
||||
<div className={styles.tooltipContent}>
|
||||
<strong>{tooltip?.date}</strong>
|
||||
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(tooltip?.tokens ?? 0) })}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
--daily-token-tooltip-font-size: #{type.$font-size-2xs};
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { TokenUsageGranularity } from "../../api";
|
||||
import { formatCompactInteger } from "../../utils/numberFormat";
|
||||
import { EChart } from "./EChart";
|
||||
import styles from "./DailyTokenUsageChart.module.scss";
|
||||
|
||||
export type DailyTokenUsage = {
|
||||
bucketStartMs: number;
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
outputTokens: number;
|
||||
};
|
||||
|
||||
type TooltipItem = {
|
||||
dataIndex: number;
|
||||
};
|
||||
|
||||
const seriesColors = {
|
||||
input: "#0091ff",
|
||||
cacheRead: "#40c463",
|
||||
cacheWrite: "#3A62BA",
|
||||
output: "#E7B40B",
|
||||
} as const;
|
||||
const levelLineColor = "#E7B40B";
|
||||
const emptyBarColor = "rgba(139, 148, 158, 0.20)";
|
||||
const EMPTY_BAR_RATIO = 1;
|
||||
const DATA_HEIGHT_RATIO = 1;
|
||||
|
||||
const seriesFocus = {
|
||||
emphasis: { focus: "series" },
|
||||
blur: { itemStyle: { opacity: 0.2 } },
|
||||
} as const;
|
||||
|
||||
function colorMark(color: string): string {
|
||||
return `<span style="display:inline-block;width:8px;height:8px;margin-right:6px;border-radius:50%;background-color:${color};vertical-align:middle"></span>`;
|
||||
}
|
||||
|
||||
function formatDay(value: Date) {
|
||||
return `${value.getUTCMonth() + 1}/${value.getUTCDate()}`;
|
||||
}
|
||||
|
||||
function pad(value: number) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatAxisLabel(bucketStartMs: number, granularity: TokenUsageGranularity) {
|
||||
const value = new Date(bucketStartMs);
|
||||
if (granularity === "minute") return `${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
||||
if (granularity === "hour") return `${pad(value.getHours())}:00`;
|
||||
const day = value.getUTCDay();
|
||||
if (day === 6) return t("周六");
|
||||
if (day === 0) return t("周日");
|
||||
return formatDay(value);
|
||||
}
|
||||
|
||||
function formatTooltipTime(bucketStartMs: number, granularity: TokenUsageGranularity) {
|
||||
const value = new Date(bucketStartMs);
|
||||
if (granularity === "day") return formatDay(value);
|
||||
const time = `${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
||||
return `${value.getMonth() + 1}/${value.getDate()} ${time}`;
|
||||
}
|
||||
|
||||
function totalTokens(day: DailyTokenUsage) {
|
||||
return day.inputTokens + day.cacheReadTokens + day.cacheWriteTokens + day.outputTokens;
|
||||
}
|
||||
|
||||
export function DailyTokenUsageChart({
|
||||
data,
|
||||
granularity,
|
||||
}: {
|
||||
data: DailyTokenUsage[];
|
||||
granularity: TokenUsageGranularity;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const maximumTotal = data.reduce((maximum, day) => Math.max(maximum, totalTokens(day)), 0);
|
||||
const axisMaximum = Math.max(1, maximumTotal / DATA_HEIGHT_RATIO);
|
||||
const emptyBarHeight = axisMaximum * EMPTY_BAR_RATIO;
|
||||
const averageLevel = data.length === 0
|
||||
? 0
|
||||
: data.reduce((sum, day) => sum + totalTokens(day), 0) / data.length;
|
||||
|
||||
const option = useMemo<EChartsCoreOption>(() => ({
|
||||
animationDuration: 450,
|
||||
animationEasing: "cubicOut",
|
||||
grid: { top: 8, right: 0, bottom: 0, left: 0 },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
confine: true,
|
||||
backgroundColor: "var(--vscode-editorHoverWidget-background)",
|
||||
borderColor: "var(--vscode-editorHoverWidget-border)",
|
||||
textStyle: { color: "var(--vscode-foreground)", fontFamily: "PingFang-Medium" },
|
||||
extraCssText: "border-radius: 8px; box-shadow: 0 12px 32px rgb(0 0 0 / 30%); font-size: var(--daily-token-tooltip-font-size); line-height: 1.5;",
|
||||
axisPointer: {
|
||||
type: "shadow",
|
||||
shadowStyle: { color: "rgba(139, 148, 158, 0.14)" },
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const first = (params as TooltipItem[])[0];
|
||||
const day = data[first.dataIndex];
|
||||
return [
|
||||
formatTooltipTime(day.bucketStartMs, granularity),
|
||||
`${t("总请求")}:${formatCompactInteger(totalTokens(day))}`,
|
||||
`${colorMark(seriesColors.input)}${t("输入(非缓存)")}:${formatCompactInteger(day.inputTokens)}`,
|
||||
`${colorMark(seriesColors.cacheRead)}${t("缓存输入")}:${formatCompactInteger(day.cacheReadTokens)}`,
|
||||
`${colorMark(seriesColors.cacheWrite)}${t("缓存写入")}:${formatCompactInteger(day.cacheWriteTokens)}`,
|
||||
`${colorMark(seriesColors.output)}${t("模型输出")}:${formatCompactInteger(day.outputTokens)}`,
|
||||
].join("<br/>");
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: data.map(({ bucketStartMs }) => bucketStartMs),
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: "rgba(139, 148, 158, 0.32)" } },
|
||||
axisLabel: {
|
||||
interval: "auto",
|
||||
hideOverlap: true,
|
||||
formatter: (_value: string, index: number) => formatAxisLabel(data[index].bucketStartMs, granularity),
|
||||
color: "#8c8c8c",
|
||||
fontFamily: "HFKos",
|
||||
margin: 14,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
show: false,
|
||||
min: 0,
|
||||
max: axisMaximum,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t("无用量"),
|
||||
type: "bar",
|
||||
stack: "empty-placeholder",
|
||||
data: data.map((day) => totalTokens(day) === 0 ? emptyBarHeight : 0),
|
||||
barMaxWidth: 18,
|
||||
silent: true,
|
||||
z: 0,
|
||||
itemStyle: { color: emptyBarColor, borderRadius: [2, 2, 0, 0] },
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
{
|
||||
name: t("输入(非缓存)"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ inputTokens }) => inputTokens),
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: seriesColors.input },
|
||||
...seriesFocus,
|
||||
},
|
||||
{
|
||||
name: t("缓存输入"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ cacheReadTokens }) => cacheReadTokens),
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: seriesColors.cacheRead },
|
||||
...seriesFocus,
|
||||
},
|
||||
{
|
||||
name: t("缓存写入"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ cacheWriteTokens }) => cacheWriteTokens),
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: seriesColors.cacheWrite },
|
||||
...seriesFocus,
|
||||
},
|
||||
{
|
||||
name: t("模型输出"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ outputTokens }) => outputTokens),
|
||||
barMaxWidth: 18,
|
||||
barGap: "-100%",
|
||||
itemStyle: { color: seriesColors.output, borderRadius: [2, 2, 0, 0] },
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: "none",
|
||||
lineStyle: { color: levelLineColor, opacity: hovered ? 1 : 0, type: "dashed", width: 2 },
|
||||
label: {
|
||||
show: hovered,
|
||||
position: "insideStartTop",
|
||||
formatter: t("平均"),
|
||||
color: levelLineColor,
|
||||
distance: 8,
|
||||
},
|
||||
data: [{ yAxis: averageLevel }],
|
||||
},
|
||||
...seriesFocus,
|
||||
},
|
||||
],
|
||||
}), [averageLevel, axisMaximum, data, emptyBarHeight, granularity, hovered]);
|
||||
|
||||
return <EChart
|
||||
option={option}
|
||||
className={styles.root}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BarChart, LineChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
|
||||
import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { useEffect, useRef, type MouseEventHandler } from "react";
|
||||
import styles from "./EChart.module.scss";
|
||||
|
||||
use([BarChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
|
||||
|
||||
type EChartProps = {
|
||||
option: EChartsCoreOption;
|
||||
className?: string;
|
||||
onMouseEnter?: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave?: MouseEventHandler<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export function EChart({ option, className = styles.root, onMouseEnter, onMouseLeave }: EChartProps) {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = element.current;
|
||||
if (!node) return;
|
||||
const chart = init(node, undefined, { renderer: "canvas" });
|
||||
const observer = new ResizeObserver(() => chart.resize());
|
||||
observer.observe(node);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
chart.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const node = element.current;
|
||||
if (node) getInstanceByDom(node)?.setOption(option, { notMerge: true });
|
||||
}, [option]);
|
||||
|
||||
return <div ref={element} className={className} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import type { LlmCall } from "../../api";
|
||||
import { EChart } from "./EChart";
|
||||
|
||||
export function LatencyChart({ calls }: { calls: LlmCall[] }) {
|
||||
const points = calls.slice(0, 20).reverse();
|
||||
const option: EChartsCoreOption = {
|
||||
animationDuration: 280,
|
||||
color: ["#d79a62", "#ad84cf"],
|
||||
grid: { top: 22, right: 18, bottom: 30, left: 48 },
|
||||
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
|
||||
tooltip: { trigger: "axis" },
|
||||
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
|
||||
yAxis: { type: "value", axisLabel: { color: "#888", formatter: "{value} ms" }, splitLine: { lineStyle: { color: "#8882" } } },
|
||||
series: [
|
||||
{ name: "TTFT", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttft_ms ?? 0) },
|
||||
{ name: t("总耗时"), type: "line", smooth: true, symbol: "none", data: points.map((call) => call.duration_ms ?? 0) },
|
||||
],
|
||||
};
|
||||
return <EChart option={option} />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import type { LlmCall } from "../../api";
|
||||
import { EChart } from "./EChart";
|
||||
|
||||
export function TokenTrendChart({ calls }: { calls: LlmCall[] }) {
|
||||
const points = calls.slice(0, 20).reverse();
|
||||
const option: EChartsCoreOption = {
|
||||
animationDuration: 280,
|
||||
color: ["#6f91f4", "#62c6a3"],
|
||||
grid: { top: 22, right: 18, bottom: 30, left: 48 },
|
||||
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
|
||||
tooltip: { trigger: "axis" },
|
||||
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
|
||||
yAxis: { type: "value", axisLabel: { color: "#888" }, splitLine: { lineStyle: { color: "#8882" } } },
|
||||
series: [
|
||||
{ name: t("输入 Token"), type: "bar", stack: "tokens", data: points.map((call) => call.input_tokens ?? 0), barMaxWidth: 22 },
|
||||
{ name: t("输出 Token"), type: "bar", stack: "tokens", data: points.map((call) => call.output_tokens ?? 0), barMaxWidth: 22 },
|
||||
],
|
||||
};
|
||||
return <EChart option={option} />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useAppStore } from "../../store/appStore";
|
||||
import controls from "../ui/Controls.module.scss";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
const CaReady = createContext(false);
|
||||
const ModelsReady = createContext(false);
|
||||
|
||||
export function CursorCaProvider({ children }: { children: ReactNode }) {
|
||||
const { cursorHarness } = useAppStore();
|
||||
return <CaReady.Provider value={cursorHarness?.ca === "ready"}>{children}</CaReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh, children }: { busy: boolean; waitingForRefresh: boolean; onInitialize: () => void; onRefresh: () => void; children: ReactNode }) {
|
||||
const ready = useContext(CaReady);
|
||||
const { cursorHarness } = useAppStore();
|
||||
if (ready) return children;
|
||||
const unsupported = cursorHarness?.ca === "unsupported";
|
||||
const installedLocally = cursorHarness?.ca === "untrusted";
|
||||
return <div className={styles.gate}>
|
||||
<strong>{unsupported ? t("当前系统暂不支持安装 CA") : installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
|
||||
<span>{unsupported ? t("请使用 macOS 或 Windows。") : installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
|
||||
{!unsupported && <button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function CursorModelProvider({ children }: { children: ReactNode }) {
|
||||
const { models } = useAppStore();
|
||||
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorModelGate({ onAdd, children }: { onAdd: () => void; children: ReactNode }) {
|
||||
const ready = useContext(ModelsReady);
|
||||
if (ready) return children;
|
||||
return <div className={styles.gate}>
|
||||
<strong>{t("还没有可供 Cursor 使用的模型")}</strong>
|
||||
<span>{t("Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。")}</span>
|
||||
<button className={controls.primary} onClick={onAdd}>{t("添加模型")}</button>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api";
|
||||
import { FormField, TextInput } from "../ui/FormControls";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { JsonEditor } from "../ui/JsonEditor";
|
||||
import { Combobox, MultiCombobox, Select } from "../ui/Select";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import controls from "../ui/Controls.module.scss";
|
||||
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||
import { claudeIcon, openAiIcon } from "../ui/icons";
|
||||
import { defaultCustomHeaders, defaultCustomHeadersText } from "../../utils/providerDefaults";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelDraft = {
|
||||
providerMode: string;
|
||||
provider: ProviderInput;
|
||||
model: ModelInput;
|
||||
modelIds: string[];
|
||||
headersText: string;
|
||||
extraText: string;
|
||||
customRequestUrl: boolean;
|
||||
};
|
||||
|
||||
export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||
providerMode: "new",
|
||||
provider: { name: "", provider_type: "openai-responses", base_url: "", api_key: "", custom_headers: { ...defaultCustomHeaders }, extra_params: {} },
|
||||
model: { model_id: "", display_name: "", endpoint_type: "openai-responses", request_url: "", enabled: true, sort_order: 0, context_window_tokens: null, max_output_tokens: null, reasoning_enabled: true, reasoning_effort: null, supports_image_generation: false },
|
||||
modelIds: [],
|
||||
headersText: defaultCustomHeadersText,
|
||||
extraText: "{}",
|
||||
customRequestUrl: false,
|
||||
});
|
||||
|
||||
export function CursorModelEditor({ draft, providers, editing, modelOptions, discovering, onChange, onDiscover }: {
|
||||
draft: CursorModelDraft;
|
||||
providers: Provider[];
|
||||
editing: boolean;
|
||||
modelOptions: string[];
|
||||
discovering: boolean;
|
||||
onChange: (draft: CursorModelDraft) => void;
|
||||
onDiscover: () => void;
|
||||
}) {
|
||||
const setProvider = (patch: Partial<ProviderInput>) => onChange({ ...draft, provider: { ...draft.provider, ...patch } });
|
||||
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
|
||||
const canDiscover = draft.providerMode !== "new"
|
||||
|| Boolean(draft.provider.base_url.trim() && draft.provider.api_key?.trim());
|
||||
const selectProvider = (providerMode: string) => {
|
||||
const endpointType = providerMode === "new"
|
||||
? draft.provider.provider_type
|
||||
: providers.find((provider) => String(provider.provider_id) === providerMode)?.provider_type;
|
||||
onChange({ ...draft, providerMode, model: endpointType ? { ...draft.model, endpoint_type: endpointType } : draft.model });
|
||||
};
|
||||
const setEndpointType = (endpoint_type: ProviderType) => onChange({
|
||||
...draft,
|
||||
provider: draft.providerMode === "new" ? { ...draft.provider, provider_type: endpoint_type } : draft.provider,
|
||||
model: { ...draft.model, endpoint_type },
|
||||
});
|
||||
const setModelIds = (modelIds: string[]) => onChange({
|
||||
...draft,
|
||||
modelIds,
|
||||
model: {
|
||||
...draft.model,
|
||||
model_id: modelIds[0] ?? "",
|
||||
display_name: modelIds.length === 1 && draft.modelIds.length !== 1 ? modelIds[0] : draft.model.display_name,
|
||||
},
|
||||
});
|
||||
return <div className={styles.editor}>
|
||||
{!editing && <FormField label={t("上游")} hint={t("选择已有上游,或创建一个新的上游。")}><Select ariaLabel={t("选择上游")} value={draft.providerMode} options={[
|
||||
{ value: "new", label: t("新建上游") },
|
||||
...providers.map((provider) => ({ value: String(provider.provider_id), label: provider.name })),
|
||||
]} onChange={selectProvider} /></FormField>}
|
||||
|
||||
<div className={styles.grid}>
|
||||
{!editing && draft.providerMode === "new" && <>
|
||||
<FormField label="Base URL" hint={t("模型服务的 API 根地址,例如 https://api.openai.com/v1。")}><TextInput placeholder="例如:https://api.openai.com/v1" value={draft.provider.base_url} onChange={(event) => setProvider({ base_url: event.target.value })} /></FormField>
|
||||
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}><TextInput type="password" placeholder="例如:sk-xxxxxx" autoComplete="off" value={draft.provider.api_key ?? ""} onChange={(event) => setProvider({ api_key: event.target.value })} /></FormField>
|
||||
</>}
|
||||
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[
|
||||
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField>
|
||||
{(editing || draft.modelIds.length <= 1) && <FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给上游的模型名称。")}><TextInput placeholder="例如:GPT-4.1" value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>}
|
||||
<FormField className={styles.fullWidth} label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
|
||||
? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} />
|
||||
: <MultiCombobox value={draft.modelIds} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={setModelIds} />
|
||||
}</FormField>
|
||||
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
|
||||
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
|
||||
{!editing && draft.providerMode === "new" && <>
|
||||
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={draft.extraText} onChange={(extraText) => onChange({ ...draft, extraText })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("附加到该上游所有请求的自定义请求头,值必须是字符串。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={draft.headersText} onChange={(headersText) => onChange({ ...draft, headersText })} /></FormField>
|
||||
</>}
|
||||
<div className={`${styles.switches} ${styles.fullWidth}`}>
|
||||
<label><TooltipTrigger label={t("模型是否允许被 Cursor 选择使用。")}><span>{t("启用模型")}</span></TooltipTrigger><Switch label={t("启用模型")} checked={draft.model.enabled} onChange={(enabled) => setModel({ enabled })} /></label>
|
||||
<label><TooltipTrigger label={t("是否声明模型支持推理能力。")}><span>{t("启用推理")}</span></TooltipTrigger><Switch label={t("启用推理")} checked={draft.model.reasoning_enabled} onChange={(reasoning_enabled) => setModel({ reasoning_enabled })} /></label>
|
||||
<label><TooltipTrigger label={t("是否声明模型支持图片生成。")}><span>{t("图片生成")}</span></TooltipTrigger><Switch label={t("图片生成")} checked={draft.model.supports_image_generation} onChange={(supports_image_generation) => setModel({ supports_image_generation })} /></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.gate {
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
strong {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
span {
|
||||
max-width: 440px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
button {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.providerTitle {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.models {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modelRow {
|
||||
min-height: 54px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
// border-top: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
.modelName {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
.badge {
|
||||
padding: 2px 7px;
|
||||
color: var(--vscode-badge-foreground);
|
||||
background: var(--vscode-badge-background);
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
.rowActions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.command {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: var(--vscode-textCodeBlock-background);
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
user-select: text;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.switches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
label {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.fullWidth {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
top: var(--app-content-top);
|
||||
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: type.$font-size-lg;
|
||||
font-weight: bolder;
|
||||
transform: translateY(-150%);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.fixedContent {
|
||||
width: calc(100% - var(--app-page-padding) * 2);
|
||||
max-width: var(--app-page-max-width);
|
||||
padding-top: var(--app-content-top);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.fixedContent,
|
||||
.fixedSection {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fixedSection {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { VirtualPageSection } from "./VirtualPage";
|
||||
import { VirtualPage } from "./VirtualPage";
|
||||
import styles from "./PageContent.module.scss";
|
||||
|
||||
export function PageContent({ title, sections, contentClassName, fixed = false }: { title?: ReactNode; sections: VirtualPageSection[]; contentClassName?: string; fixed?: boolean }) {
|
||||
return <div className={styles.root}>
|
||||
{fixed && title != null && <div className={styles.title}>{title}</div>}
|
||||
{fixed
|
||||
? <div className={[styles.fixedContent, contentClassName].filter(Boolean).join(" ")}>{sections.map((section) => <section className={styles.fixedSection} key={section.key}>{section.content}</section>)}</div>
|
||||
: <VirtualPage title={title} sections={sections} contentClassName={contentClassName} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header,
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import styles from "./PageLayout.module.scss";
|
||||
|
||||
type PageLayoutProps = {
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function PageLayout({ as: Component = "div", className, header, footer, children }: PageLayoutProps) {
|
||||
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>
|
||||
{header && <div className={styles.header}>{header}</div>}
|
||||
<div className={styles.body}>{children}</div>
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
</Component>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: calc(100% - var(--app-page-padding) * 2);
|
||||
max-width: var(--app-page-max-width);
|
||||
padding-top: var(--app-content-top);
|
||||
padding-bottom: var(--app-page-padding);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
|
||||
top: 0;
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: type.$font-size-lg;
|
||||
font-weight: bolder;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { VirtualList } from "../virtual/VirtualListEngine";
|
||||
import styles from "./VirtualPage.module.scss";
|
||||
|
||||
export type VirtualPageSection = {
|
||||
key: string;
|
||||
estimatedHeight: number;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export function VirtualPage({ title, sections, className, contentClassName }: { title?: ReactNode; sections: VirtualPageSection[]; className?: string; contentClassName?: string }) {
|
||||
const getKey = useCallback((section: VirtualPageSection) => section.key, []);
|
||||
const estimateSize = useCallback((section: VirtualPageSection) => section.estimatedHeight, []);
|
||||
const renderItem = useCallback((section: VirtualPageSection, { index }: { index: number }) => <section className={styles.section}>
|
||||
{index === 0 && title != null && <div className={styles.title}>{title}</div>}
|
||||
{section.content}
|
||||
</section>, [title]);
|
||||
|
||||
return <VirtualList
|
||||
items={sections}
|
||||
getKey={getKey}
|
||||
estimateSize={estimateSize}
|
||||
renderItem={renderItem}
|
||||
overscan={2}
|
||||
itemGap={16}
|
||||
scrollbarSize={7}
|
||||
scrollbarInsetTop="var(--app-content-top)"
|
||||
className={[styles.root, "scroll-shadow-top", className].filter(Boolean).join(" ")}
|
||||
contentClassName={[styles.content, contentClassName].filter(Boolean).join(" ")}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
--cache-hit-track-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
|
||||
--cache-hit-value-color: var(--vscode-gitDecoration-addedResourceForeground, #4ade80);
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 82px;
|
||||
align-self: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--vscode-foreground);
|
||||
font-family: var(--oa-number-font);
|
||||
font-size: type.$font-size-xl;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ArcElement, Chart as ChartJS, Tooltip, type ChartOptions, type ScriptableContext } from "chart.js";
|
||||
import { useMemo } from "react";
|
||||
import { Doughnut } from "react-chartjs-2";
|
||||
import styles from "./CacheHitRateChart.module.scss";
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip);
|
||||
|
||||
type SegmentRadius = number | {
|
||||
outerStart: number;
|
||||
outerEnd: number;
|
||||
innerStart: number;
|
||||
innerEnd: number;
|
||||
};
|
||||
|
||||
function chartColor(context: ScriptableContext<"doughnut">) {
|
||||
const styles = getComputedStyle(context.chart.canvas);
|
||||
const variable = context.dataIndex === 0 ? "--cache-hit-value-color" : "--cache-hit-track-color";
|
||||
return styles.getPropertyValue(variable).trim();
|
||||
}
|
||||
|
||||
function segmentBorderRadius(percentage: number, dataIndex: number): SegmentRadius {
|
||||
const radius = 5;
|
||||
|
||||
if (percentage <= 0) {
|
||||
return dataIndex === 1
|
||||
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
|
||||
: 0;
|
||||
}
|
||||
|
||||
if (percentage >= 100) {
|
||||
return dataIndex === 0
|
||||
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
|
||||
: 0;
|
||||
}
|
||||
|
||||
return dataIndex === 0
|
||||
? { outerStart: radius, outerEnd: 0, innerStart: radius, innerEnd: 0 }
|
||||
: { outerStart: 0, outerEnd: radius, innerStart: 0, innerEnd: radius };
|
||||
}
|
||||
|
||||
const options: ChartOptions<"doughnut"> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "82%",
|
||||
rotation: -90,
|
||||
circumference: 180,
|
||||
animation: { duration: 450 },
|
||||
events: [],
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
},
|
||||
};
|
||||
|
||||
export function CacheHitRateChart({ rate }: { rate: number }) {
|
||||
const finiteRate = Number.isFinite(rate) ? rate : 0;
|
||||
const percentage = Math.max(0, Math.min(100, finiteRate * 100));
|
||||
const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--";
|
||||
const data = useMemo(() => ({
|
||||
labels: [t("命中"), t("未命中")],
|
||||
datasets: [{
|
||||
data: [percentage, Math.max(0, 100 - percentage)],
|
||||
backgroundColor: chartColor,
|
||||
borderWidth: 0,
|
||||
hoverBorderWidth: 0,
|
||||
selfJoin: false,
|
||||
borderRadius: (context: ScriptableContext<"doughnut">) => segmentBorderRadius(percentage, context.dataIndex),
|
||||
}],
|
||||
}), [percentage]);
|
||||
|
||||
return <div className={styles.root} role="img" aria-label={t("缓存命中率 {rate}", { rate: label })}>
|
||||
<Doughnut className={styles.canvas} data={data} options={options} />
|
||||
<div className={styles.label}>{label}</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.scroller {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 130px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
font-family: var(--oa-metrics-font);
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
transition: color 150ms ease;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
font-family: var(--oa-number-font);
|
||||
font-size: type.$font-size-xxl;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tooltipText {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from "react";
|
||||
import { formatCompactInteger, formatInteger } from "../../utils/numberFormat";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
|
||||
import { informationOutlineIcon } from "../ui/icons";
|
||||
import { CacheHitRateChart } from "./CacheHitRateChart";
|
||||
import styles from "./HomeMetrics.module.scss";
|
||||
|
||||
export type HomeMetricsData = {
|
||||
llmCalls: number;
|
||||
successfulCalls: number;
|
||||
failedCalls: number;
|
||||
tokenUsage: number;
|
||||
promptTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
};
|
||||
|
||||
const TOKEN_PRICE_PER_MILLION = {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
} as const;
|
||||
|
||||
function formatMetricValue(value: number) {
|
||||
const full = formatInteger(value);
|
||||
const compact = formatCompactInteger(value);
|
||||
return full === compact ? full : `${full} (${compact})`;
|
||||
}
|
||||
|
||||
function formatRate(value: number | null) {
|
||||
return value === null ? t("暂无数据") : `${(Math.max(0, Math.min(1, value)) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function calculateRate(numerator: number, denominator: number) {
|
||||
return denominator > 0 ? numerator / denominator : null;
|
||||
}
|
||||
|
||||
function priceTokens(tokens: number, pricePerMillion: number) {
|
||||
return (tokens / 1_000_000) * pricePerMillion;
|
||||
}
|
||||
|
||||
function formatUSD(value: number) {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function elementAnchor(element: HTMLElement): TooltipAnchor {
|
||||
return {
|
||||
contextElement: element,
|
||||
getBoundingClientRect: () => element.getBoundingClientRect(),
|
||||
};
|
||||
}
|
||||
|
||||
function InfoTooltip({ content }: { content: string }) {
|
||||
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
|
||||
|
||||
return <>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.info}
|
||||
aria-label={t("查看说明")}
|
||||
onMouseEnter={(event) => setAnchor(elementAnchor(event.currentTarget))}
|
||||
onMouseLeave={() => setAnchor(null)}
|
||||
onFocus={(event) => setAnchor(elementAnchor(event.currentTarget))}
|
||||
onBlur={() => setAnchor(null)}
|
||||
><Icon icon={informationOutlineIcon} size="1.1em" /></button>
|
||||
<Tooltip anchor={anchor}>
|
||||
<div className={styles.tooltipText}>{content}</div>
|
||||
</Tooltip>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function HomeMetrics({ data }: { data: HomeMetricsData }) {
|
||||
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
|
||||
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
|
||||
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
|
||||
const cacheReuseRate = calculateRate(
|
||||
data.cacheReadTokens,
|
||||
data.cacheReadTokens + data.cacheWriteTokens + inputTokens,
|
||||
);
|
||||
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
|
||||
const costs = {
|
||||
input: priceTokens(inputTokens, TOKEN_PRICE_PER_MILLION.input),
|
||||
output: priceTokens(outputTokens, TOKEN_PRICE_PER_MILLION.output),
|
||||
cacheRead: priceTokens(data.cacheReadTokens, TOKEN_PRICE_PER_MILLION.cacheRead),
|
||||
cacheWrite: priceTokens(data.cacheWriteTokens, TOKEN_PRICE_PER_MILLION.cacheWrite),
|
||||
};
|
||||
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
|
||||
const cacheCost = costs.cacheRead + costs.cacheWrite;
|
||||
const cacheTooltip = [
|
||||
t("当前:{rate}", { rate: formatRate(defaultCacheHitRate) }),
|
||||
t("公式:缓存读取 /(缓存读取 + 非缓存输入)"),
|
||||
t("默认 {defaultRate} / 计入创建 {reuseRate}", {
|
||||
defaultRate: formatRate(defaultCacheHitRate),
|
||||
reuseRate: formatRate(cacheReuseRate),
|
||||
}),
|
||||
].join("\n");
|
||||
const callsTooltip = [
|
||||
t("按历史 LLM 调用记录汇总,进行中的调用不计入。"),
|
||||
"",
|
||||
t("总调用:{count}", { count: formatMetricValue(data.llmCalls) }),
|
||||
t("成功调用:{count}", { count: formatMetricValue(data.successfulCalls) }),
|
||||
t("异常调用:{count}", { count: formatMetricValue(data.failedCalls) }),
|
||||
t("成功占比:{rate}", { rate: formatRate(successfulCallRate) }),
|
||||
].join("\n");
|
||||
const tokensTooltip = [
|
||||
t("总请求 Token 包含提示词和模型输出。"),
|
||||
"",
|
||||
t("总请求:{tokens}", { tokens: formatMetricValue(data.tokenUsage) }),
|
||||
t("提示词:{tokens}", { tokens: formatMetricValue(data.promptTokens) }),
|
||||
t("输出推算:{tokens}", { tokens: formatMetricValue(outputTokens) }),
|
||||
t("非缓存输入:{tokens}", { tokens: formatMetricValue(inputTokens) }),
|
||||
t("缓存读取:{tokens}", { tokens: formatMetricValue(data.cacheReadTokens) }),
|
||||
t("缓存写入:{tokens}", { tokens: formatMetricValue(data.cacheWriteTokens) }),
|
||||
"",
|
||||
t("缓存读写已计入提示词侧统计。"),
|
||||
].join("\n");
|
||||
const costTooltip = [
|
||||
t("按 Claude Opus 4.7 价格估算。"),
|
||||
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
|
||||
"",
|
||||
t("普通输入:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(inputTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.input,
|
||||
cost: formatUSD(costs.input),
|
||||
}),
|
||||
t("模型输出:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(outputTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.output,
|
||||
cost: formatUSD(costs.output),
|
||||
}),
|
||||
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(data.cacheReadTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.cacheRead,
|
||||
cost: formatUSD(costs.cacheRead),
|
||||
}),
|
||||
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(data.cacheWriteTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.cacheWrite,
|
||||
cost: formatUSD(costs.cacheWrite),
|
||||
}),
|
||||
"",
|
||||
t("合计:{cost}", { cost: formatUSD(totalCost) }),
|
||||
].join("\n");
|
||||
|
||||
return <div className={styles.scroller}>
|
||||
<section className={styles.root} aria-label={t("调用统计")}>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div>
|
||||
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} />
|
||||
</article>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.value} title={formatInteger(data.llmCalls)}>{formatCompactInteger(data.llmCalls)}</div>
|
||||
<div className={styles.secondary}>{t("成功 {successful} / 异常 {failed}", {
|
||||
successful: formatCompactInteger(data.successfulCalls),
|
||||
failed: formatCompactInteger(data.failedCalls),
|
||||
})}</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("Token 消耗")}<InfoTooltip content={tokensTooltip} /></div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.value} title={formatInteger(data.tokenUsage)}>{formatCompactInteger(data.tokenUsage)}</div>
|
||||
<div className={styles.secondary}>{t("提示词 {tokens}", { tokens: formatCompactInteger(data.promptTokens) })}</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("价值估算")}<InfoTooltip content={costTooltip} /></div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.value} title={formatUSD(totalCost)}>{formatUSD(totalCost)}</div>
|
||||
<div className={styles.secondary}>{t("缓存读写 {cost}", { cost: formatUSD(cacheCost) })}</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root,
|
||||
.presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterRow { width: 100%; }
|
||||
|
||||
.root {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.presets {
|
||||
overflow: hidden;
|
||||
// border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 5px;
|
||||
|
||||
button {
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--vscode-sideBar-border);
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child { border-right: 0; }
|
||||
&:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
|
||||
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: fixed;
|
||||
z-index: 14000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
overflow: visible;
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
|
||||
&:focus { border-color: var(--vscode-focusBorder); }
|
||||
}
|
||||
}
|
||||
|
||||
.popoverActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { parseTimeInput } from "../../utils/parseTimeInput";
|
||||
import controls from "../ui/Controls.module.scss";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { MultiSelect, type MultiSelectOption } from "../ui/MultiSelect";
|
||||
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||
import { refreshIcon } from "../ui/icons";
|
||||
import styles from "./OverviewTimeRangeFilter.module.scss";
|
||||
|
||||
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
|
||||
|
||||
const presets: Array<{ value: Exclude<OverviewRangePreset, "custom">; label: string }> = [
|
||||
{ value: "hour", label: "近1小时" },
|
||||
{ value: "today", label: "近1自然日" },
|
||||
{ value: "ten-minutes", label: "近10分钟" },
|
||||
{ value: "week", label: "近一周" },
|
||||
{ value: "month", label: "近一个月" },
|
||||
];
|
||||
|
||||
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, providerOptions, selectedModels, selectedProviders, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onSelectedProvidersChange, onCustomApply, onRefresh }: {
|
||||
value: OverviewRangePreset;
|
||||
customOpen: boolean;
|
||||
customStart: string;
|
||||
customEnd: string;
|
||||
modelOptions: MultiSelectOption[];
|
||||
providerOptions: MultiSelectOption[];
|
||||
selectedModels: string[];
|
||||
selectedProviders: string[];
|
||||
busy: boolean;
|
||||
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
|
||||
onCustomOpenChange: (open: boolean) => void;
|
||||
onCustomStartChange: (value: string) => void;
|
||||
onCustomEndChange: (value: string) => void;
|
||||
onSelectedModelsChange: (value: string[]) => void;
|
||||
onSelectedProvidersChange: (value: string[]) => void;
|
||||
onCustomApply: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const customButton = useRef<HTMLButtonElement>(null);
|
||||
const popover = useRef<HTMLDivElement>(null);
|
||||
const popoverId = useId();
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 300, maxHeight: 480 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!customOpen || !customButton.current || !popover.current) return;
|
||||
return autoUpdate(customButton.current, popover.current, () => void computePosition(customButton.current!, popover.current!, {
|
||||
placement: "bottom-end",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
|
||||
padding: 10,
|
||||
apply: ({ availableHeight }) => setPosition((current) => ({
|
||||
...current,
|
||||
maxHeight: Math.max(240, availableHeight),
|
||||
})),
|
||||
})],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [customOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!customOpen) return;
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!customButton.current?.contains(target) && !popover.current?.contains(target)) onCustomOpenChange(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
}, [customOpen, onCustomOpenChange]);
|
||||
|
||||
const parsedStart = parseTimeInput(customStart);
|
||||
const parsedEnd = parseTimeInput(customEnd);
|
||||
const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd;
|
||||
|
||||
return <div className={styles.root} aria-label={t("概览时间范围")}>
|
||||
<div className={styles.presets}>
|
||||
{presets.map((preset) => <button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
aria-pressed={value === preset.value}
|
||||
onClick={() => onSelect(preset.value)}
|
||||
>{preset.label}</button>)}
|
||||
<button
|
||||
ref={customButton}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={customOpen ? popoverId : undefined}
|
||||
aria-expanded={customOpen}
|
||||
aria-pressed={value === "custom"}
|
||||
onClick={() => onCustomOpenChange(!customOpen)}
|
||||
>{t("自定义")}</button>
|
||||
</div>
|
||||
<TooltipTrigger label={t("刷新")}><button className={controls.iconButton} aria-label={t("刷新")} disabled={busy} onClick={onRefresh}>
|
||||
<Icon className={busy ? controls.spin : ""} icon={refreshIcon} size="1.1em" />
|
||||
</button></TooltipTrigger>
|
||||
{customOpen && createPortal(<div
|
||||
id={popoverId}
|
||||
ref={popover}
|
||||
className={styles.popover}
|
||||
role="dialog"
|
||||
aria-label={t("自定义概览筛选")}
|
||||
style={position}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCustomOpenChange(false);
|
||||
customButton.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
|
||||
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
|
||||
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
|
||||
<div className={styles.filterRow}><MultiSelect label={t("上游")} value={selectedProviders} options={providerOptions} onChange={onSelectedProvidersChange} /></div>
|
||||
<div className={styles.popoverActions}>
|
||||
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
|
||||
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
|
||||
</div>
|
||||
</div>, document.body)}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.root {
|
||||
--oa-scrollbar-size: 8px;
|
||||
--oa-scrollbar-min-thumb-size: 20px;
|
||||
--oa-scrollbar-inset-top: 0px;
|
||||
--oa-scrollbar-thumb: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.1));
|
||||
--oa-scrollbar-thumb-hover: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.12));
|
||||
--oa-scrollbar-thumb-active: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.12));
|
||||
--oa-scrollbar-hit-size: max(12px, var(--oa-scrollbar-size));
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&.always .scrollbar,
|
||||
&.active .scrollbar,
|
||||
&:hover .scrollbar {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&.active .thumb {
|
||||
background: var(--oa-scrollbar-thumb-active);
|
||||
}
|
||||
}
|
||||
|
||||
.viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
position: absolute;
|
||||
top: var(--oa-scrollbar-inset-top);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
width: var(--oa-scrollbar-hit-size);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: var(--oa-scrollbar-size);
|
||||
min-height: var(--oa-scrollbar-min-thumb-size);
|
||||
background: var(--oa-scrollbar-thumb);
|
||||
border-radius: 999px;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: var(--oa-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react"
|
||||
import {
|
||||
useScrollableModel,
|
||||
type ScrollAreaApi,
|
||||
type ScrollAreaState,
|
||||
} from "./useScrollableModel"
|
||||
import styles from "./ScrollArea.module.scss"
|
||||
|
||||
export type { ScrollAreaApi, ScrollAreaState } from "./useScrollableModel"
|
||||
|
||||
export interface ScrollAreaProps {
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
viewportClassName?: string
|
||||
contentClassName?: string
|
||||
|
||||
children: ReactNode | ((state: ScrollAreaState) => ReactNode)
|
||||
|
||||
contentHeight?: number
|
||||
defaultScrollTop?: number
|
||||
scrollTop?: number
|
||||
onScroll?: (state: ScrollAreaState) => void
|
||||
onViewportResize?: (size: { width: number; height: number }) => void
|
||||
|
||||
scrollbarSize?: number
|
||||
minThumbSize?: number
|
||||
scrollbarVisibility?: "auto" | "always" | "hidden"
|
||||
scrollbarInsetTop?: CSSProperties["top"]
|
||||
|
||||
viewportRef?: Ref<HTMLDivElement>
|
||||
contentRef?: Ref<HTMLDivElement>
|
||||
onReady?: (api: ScrollAreaApi) => void
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
|
||||
if (!ref) return
|
||||
if (typeof ref === "function") {
|
||||
ref(value)
|
||||
} else {
|
||||
ref.current = value
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function normalizeWheelDelta(event: WheelEvent, viewportHeight: number): number {
|
||||
let deltaY = event.deltaY
|
||||
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
||||
deltaY *= 16
|
||||
} else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||
deltaY *= viewportHeight
|
||||
}
|
||||
|
||||
if (deltaY !== 0 && Math.abs(deltaY) < 1) {
|
||||
if (deltaY < 0) return -1
|
||||
return 1
|
||||
}
|
||||
|
||||
return deltaY
|
||||
}
|
||||
|
||||
export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
function ScrollArea(props, forwardedRef) {
|
||||
const {
|
||||
className,
|
||||
style,
|
||||
viewportClassName,
|
||||
contentClassName,
|
||||
children,
|
||||
contentHeight,
|
||||
defaultScrollTop,
|
||||
scrollTop,
|
||||
onScroll,
|
||||
onViewportResize,
|
||||
scrollbarSize = 8,
|
||||
minThumbSize = 20,
|
||||
scrollbarVisibility = "auto",
|
||||
scrollbarInsetTop = 0,
|
||||
viewportRef,
|
||||
contentRef,
|
||||
onReady,
|
||||
} = props
|
||||
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const localViewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||
const thumbRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
pointerOffsetY: number
|
||||
} | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const { state, api, metricsRef, scrollToNow } = useScrollableModel({
|
||||
viewportRef: localViewportRef,
|
||||
trackRef,
|
||||
thumbRef,
|
||||
contentHeight,
|
||||
defaultScrollTop,
|
||||
scrollTop,
|
||||
minThumbSize,
|
||||
onScroll,
|
||||
onViewportResize,
|
||||
})
|
||||
|
||||
useImperativeHandle(forwardedRef, () => hostRef.current as HTMLDivElement)
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.(api)
|
||||
}, [api, onReady])
|
||||
|
||||
const setViewportRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
localViewportRef.current = node
|
||||
assignRef(viewportRef, node)
|
||||
},
|
||||
[viewportRef]
|
||||
)
|
||||
|
||||
const setContentRef = useCallback(
|
||||
(node: HTMLDivElement | null) => assignRef(contentRef, node),
|
||||
[contentRef]
|
||||
)
|
||||
|
||||
const scrollbarHidden =
|
||||
scrollbarVisibility === "hidden" ||
|
||||
!state.isScrollable ||
|
||||
state.viewportHeight <= 0
|
||||
|
||||
const hostClassNames = [styles.root]
|
||||
if (scrollbarVisibility === "always") hostClassNames.push(styles.always)
|
||||
if (dragging || state.isScrolling) hostClassNames.push(styles.active)
|
||||
if (className) hostClassNames.push(className)
|
||||
const hostClassName = hostClassNames.join(" ")
|
||||
|
||||
const hostStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
...style,
|
||||
"--oa-scrollbar-size": `${scrollbarSize}px`,
|
||||
"--oa-scrollbar-min-thumb-size": `${minThumbSize}px`,
|
||||
"--oa-scrollbar-inset-top":
|
||||
typeof scrollbarInsetTop === "number"
|
||||
? `${scrollbarInsetTop}px`
|
||||
: scrollbarInsetTop,
|
||||
} as CSSProperties),
|
||||
[minThumbSize, scrollbarInsetTop, scrollbarSize, style]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const thumb = thumbRef.current
|
||||
const track = trackRef.current
|
||||
if (!thumb || !track) return
|
||||
|
||||
const setScrollTopFromThumb = (clientY: number) => {
|
||||
const currentDrag = dragRef.current
|
||||
if (!currentDrag) return
|
||||
|
||||
const metrics = metricsRef.current
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
const desiredThumbTop = clamp(
|
||||
clientY - trackRect.top - currentDrag.pointerOffsetY,
|
||||
0,
|
||||
metrics.maxThumbTop
|
||||
)
|
||||
let nextScrollTop = 0
|
||||
if (metrics.maxThumbTop > 0) {
|
||||
nextScrollTop = (desiredThumbTop / metrics.maxThumbTop) * metrics.maxScrollTop
|
||||
}
|
||||
|
||||
scrollToNow(nextScrollTop, true, metrics.maxScrollTop)
|
||||
}
|
||||
|
||||
const stopDrag = (event: PointerEvent) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||
if (thumb.hasPointerCapture(event.pointerId)) {
|
||||
thumb.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
dragRef.current = null
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
const handleThumbPointerDown = (event: PointerEvent) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return
|
||||
|
||||
event.preventDefault()
|
||||
thumb.setPointerCapture(event.pointerId)
|
||||
|
||||
const metrics = metricsRef.current
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
pointerOffsetY: clamp(
|
||||
event.clientY - trackRect.top - metrics.thumbTop,
|
||||
0,
|
||||
metrics.thumbHeight
|
||||
),
|
||||
}
|
||||
setDragging(true)
|
||||
}
|
||||
|
||||
const handleThumbPointerMove = (event: PointerEvent) => {
|
||||
if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return
|
||||
event.preventDefault()
|
||||
setScrollTopFromThumb(event.clientY)
|
||||
}
|
||||
|
||||
const handleTrackPointerDown = (event: PointerEvent) => {
|
||||
if (event.target !== track) return
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const rect = track.getBoundingClientRect()
|
||||
const metrics = metricsRef.current
|
||||
const desiredThumbTop = clamp(
|
||||
event.clientY - rect.top - metrics.thumbHeight / 2,
|
||||
0,
|
||||
metrics.maxThumbTop
|
||||
)
|
||||
let nextScrollTop = 0
|
||||
if (metrics.maxThumbTop > 0) {
|
||||
nextScrollTop = (desiredThumbTop / metrics.maxThumbTop) * metrics.maxScrollTop
|
||||
}
|
||||
scrollToNow(nextScrollTop, true, metrics.maxScrollTop)
|
||||
}
|
||||
|
||||
thumb.addEventListener("pointerdown", handleThumbPointerDown)
|
||||
thumb.addEventListener("pointermove", handleThumbPointerMove)
|
||||
thumb.addEventListener("pointerup", stopDrag)
|
||||
thumb.addEventListener("pointercancel", stopDrag)
|
||||
thumb.addEventListener("lostpointercapture", stopDrag)
|
||||
track.addEventListener("pointerdown", handleTrackPointerDown)
|
||||
|
||||
return () => {
|
||||
thumb.removeEventListener("pointerdown", handleThumbPointerDown)
|
||||
thumb.removeEventListener("pointermove", handleThumbPointerMove)
|
||||
thumb.removeEventListener("pointerup", stopDrag)
|
||||
thumb.removeEventListener("pointercancel", stopDrag)
|
||||
thumb.removeEventListener("lostpointercapture", stopDrag)
|
||||
track.removeEventListener("pointerdown", handleTrackPointerDown)
|
||||
}
|
||||
}, [metricsRef, scrollToNow])
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
const viewport = localViewportRef.current
|
||||
if (!host || !viewport) return
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.defaultPrevented || event.ctrlKey) return
|
||||
|
||||
const metrics = metricsRef.current
|
||||
if (!metrics.isScrollable) return
|
||||
|
||||
const deltaY = normalizeWheelDelta(event, metrics.viewportHeight)
|
||||
if (deltaY === 0) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const currentTop = viewport.scrollTop
|
||||
const nextTop = clamp(currentTop + deltaY, 0, metrics.maxScrollTop)
|
||||
if (nextTop === currentTop) return
|
||||
|
||||
scrollToNow(nextTop, true, metrics.maxScrollTop)
|
||||
}
|
||||
|
||||
host.addEventListener("wheel", handleWheel, { passive: false })
|
||||
return () => {
|
||||
host.removeEventListener("wheel", handleWheel)
|
||||
}
|
||||
}, [metricsRef, scrollToNow])
|
||||
|
||||
let renderedChildren: ReactNode = children as ReactNode
|
||||
if (typeof children === "function") renderedChildren = children(state)
|
||||
let scrollbarClassName = styles.scrollbar
|
||||
if (scrollbarHidden) scrollbarClassName = `${styles.scrollbar} ${styles.hidden}`
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={hostClassName}
|
||||
style={hostStyle}
|
||||
data-scrolled={state.scrollTop > 0 ? "true" : "false"}
|
||||
>
|
||||
<div
|
||||
ref={setViewportRef}
|
||||
className={[styles.viewport, viewportClassName ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<div ref={setContentRef} className={[styles.content, contentClassName ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{renderedChildren}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={scrollbarClassName}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div ref={trackRef} className={styles.track} />
|
||||
<div
|
||||
ref={thumbRef}
|
||||
className={styles.thumb}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react"
|
||||
|
||||
export interface ScrollAreaApi {
|
||||
scrollTo(top: number): void
|
||||
scrollBy(deltaTop: number): void
|
||||
scrollToTop(): void
|
||||
scrollToBottom(): void
|
||||
getScrollElement(): HTMLDivElement | null
|
||||
}
|
||||
|
||||
export interface ScrollAreaState {
|
||||
scrollTop: number
|
||||
viewportHeight: number
|
||||
scrollHeight: number
|
||||
isScrollable: boolean
|
||||
isScrolling: boolean
|
||||
}
|
||||
|
||||
export interface ScrollMetrics extends ScrollAreaState {
|
||||
maxScrollTop: number
|
||||
trackHeight: number
|
||||
thumbHeight: number
|
||||
thumbTop: number
|
||||
maxThumbTop: number
|
||||
}
|
||||
|
||||
interface UseScrollableModelOptions {
|
||||
viewportRef: RefObject<HTMLDivElement | null>
|
||||
trackRef: RefObject<HTMLDivElement | null>
|
||||
thumbRef: RefObject<HTMLDivElement | null>
|
||||
contentHeight?: number
|
||||
defaultScrollTop?: number
|
||||
scrollTop?: number
|
||||
minThumbSize?: number
|
||||
onScroll?: (state: ScrollAreaState) => void
|
||||
onViewportResize?: (size: { width: number; height: number }) => void
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function readScrollHeight(
|
||||
element: HTMLDivElement | null,
|
||||
contentHeight?: number
|
||||
): number {
|
||||
if (typeof contentHeight === "number") return Math.max(0, contentHeight)
|
||||
if (!element) return contentHeight ?? 0
|
||||
return element.scrollHeight
|
||||
}
|
||||
|
||||
function measureScrollArea(
|
||||
element: HTMLDivElement | null,
|
||||
track: HTMLDivElement | null,
|
||||
contentHeight: number | undefined,
|
||||
minThumbSize: number,
|
||||
isScrolling: boolean
|
||||
): ScrollMetrics {
|
||||
const scrollHeight = readScrollHeight(element, contentHeight)
|
||||
const viewportHeight = element?.clientHeight ?? 0
|
||||
const trackHeight = track?.clientHeight ?? viewportHeight
|
||||
const maxScrollTop = Math.max(0, scrollHeight - viewportHeight)
|
||||
const scrollTop = clamp(element?.scrollTop ?? 0, 0, maxScrollTop)
|
||||
const isScrollable = scrollHeight > viewportHeight + 1
|
||||
|
||||
let rawThumbHeight = 0
|
||||
if (scrollHeight > 0) {
|
||||
rawThumbHeight = (trackHeight * viewportHeight) / scrollHeight
|
||||
}
|
||||
let thumbHeight = 0
|
||||
if (isScrollable && trackHeight > 0) {
|
||||
thumbHeight = Math.min(trackHeight, Math.max(minThumbSize, rawThumbHeight))
|
||||
}
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
let thumbTop = 0
|
||||
if (maxScrollTop > 0) thumbTop = (scrollTop / maxScrollTop) * maxThumbTop
|
||||
|
||||
return {
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
scrollHeight,
|
||||
isScrollable,
|
||||
isScrolling,
|
||||
maxScrollTop,
|
||||
trackHeight,
|
||||
thumbHeight,
|
||||
thumbTop,
|
||||
maxThumbTop,
|
||||
}
|
||||
}
|
||||
|
||||
function toState(metrics: ScrollMetrics): ScrollAreaState {
|
||||
return {
|
||||
scrollTop: metrics.scrollTop,
|
||||
viewportHeight: metrics.viewportHeight,
|
||||
scrollHeight: metrics.scrollHeight,
|
||||
isScrollable: metrics.isScrollable,
|
||||
isScrolling: metrics.isScrolling,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldCommitState(
|
||||
previous: ScrollAreaState,
|
||||
next: ScrollAreaState
|
||||
): boolean {
|
||||
return (
|
||||
previous.viewportHeight !== next.viewportHeight ||
|
||||
previous.scrollHeight !== next.scrollHeight ||
|
||||
previous.isScrollable !== next.isScrollable ||
|
||||
previous.isScrolling !== next.isScrolling ||
|
||||
(previous.scrollTop > 0) !== (next.scrollTop > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function applyThumbStyle(
|
||||
thumb: HTMLDivElement | null,
|
||||
metrics: ScrollMetrics
|
||||
): void {
|
||||
if (!thumb) return
|
||||
thumb.style.height = `${metrics.thumbHeight}px`
|
||||
thumb.style.transform = `translateY(${metrics.thumbTop}px)`
|
||||
}
|
||||
|
||||
export function useScrollableModel(options: UseScrollableModelOptions) {
|
||||
const {
|
||||
viewportRef,
|
||||
trackRef,
|
||||
thumbRef,
|
||||
contentHeight,
|
||||
defaultScrollTop = 0,
|
||||
scrollTop,
|
||||
minThumbSize = 20,
|
||||
onScroll,
|
||||
onViewportResize,
|
||||
} = options
|
||||
|
||||
const [state, setState] = useState<ScrollAreaState>(() =>
|
||||
toState(measureScrollArea(null, null, contentHeight, minThumbSize, false))
|
||||
)
|
||||
const stateRef = useRef(state)
|
||||
const reactStateRef = useRef(state)
|
||||
const metricsRef = useRef<ScrollMetrics>(
|
||||
measureScrollArea(null, null, contentHeight, minThumbSize, false)
|
||||
)
|
||||
const scrollEndTimerRef = useRef<number | null>(null)
|
||||
const programmaticScrollTopRef = useRef<number | null>(null)
|
||||
const pendingNotificationRef = useRef<ScrollAreaState | null>(null)
|
||||
const notificationScheduledRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const defaultScrollTopAppliedRef = useRef(false)
|
||||
const onScrollRef = useRef(onScroll)
|
||||
const onViewportResizeRef = useRef(onViewportResize)
|
||||
|
||||
onScrollRef.current = onScroll
|
||||
onViewportResizeRef.current = onViewportResize
|
||||
|
||||
const scheduleNotification = useCallback((nextState: ScrollAreaState) => {
|
||||
pendingNotificationRef.current = nextState
|
||||
if (notificationScheduledRef.current) return
|
||||
|
||||
notificationScheduledRef.current = true
|
||||
queueMicrotask(() => {
|
||||
notificationScheduledRef.current = false
|
||||
const pendingState = pendingNotificationRef.current
|
||||
pendingNotificationRef.current = null
|
||||
if (!pendingState || !mountedRef.current) return
|
||||
|
||||
if (shouldCommitState(reactStateRef.current, pendingState)) {
|
||||
reactStateRef.current = pendingState
|
||||
setState(pendingState)
|
||||
}
|
||||
onScrollRef.current?.(pendingState)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const snapshot = useCallback(
|
||||
(isScrolling = false): ScrollMetrics => {
|
||||
const metrics = measureScrollArea(
|
||||
viewportRef.current,
|
||||
trackRef.current,
|
||||
contentHeight,
|
||||
minThumbSize,
|
||||
isScrolling
|
||||
)
|
||||
metricsRef.current = metrics
|
||||
applyThumbStyle(thumbRef.current, metrics)
|
||||
|
||||
const nextState = toState(metrics)
|
||||
stateRef.current = nextState
|
||||
scheduleNotification(nextState)
|
||||
|
||||
return metrics
|
||||
},
|
||||
[contentHeight, minThumbSize, scheduleNotification, thumbRef, trackRef, viewportRef]
|
||||
)
|
||||
|
||||
const scheduleScrollEnd = useCallback(() => {
|
||||
if (scrollEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollEndTimerRef.current)
|
||||
}
|
||||
|
||||
scrollEndTimerRef.current = window.setTimeout(() => {
|
||||
scrollEndTimerRef.current = null
|
||||
snapshot(false)
|
||||
}, 140)
|
||||
}, [snapshot])
|
||||
|
||||
const scrollToNow = useCallback(
|
||||
(
|
||||
top: number,
|
||||
isScrolling = true,
|
||||
knownMaxScrollTop?: number
|
||||
): ScrollMetrics | null => {
|
||||
const element = viewportRef.current
|
||||
if (!element) return null
|
||||
|
||||
let maxScrollTop = knownMaxScrollTop
|
||||
if (typeof maxScrollTop !== "number") {
|
||||
maxScrollTop = measureScrollArea(
|
||||
element,
|
||||
trackRef.current,
|
||||
contentHeight,
|
||||
minThumbSize,
|
||||
isScrolling
|
||||
).maxScrollTop
|
||||
}
|
||||
const nextTop = clamp(top, 0, maxScrollTop)
|
||||
|
||||
if (element.scrollTop !== nextTop) {
|
||||
programmaticScrollTopRef.current = nextTop
|
||||
element.scrollTop = nextTop
|
||||
}
|
||||
|
||||
const nextMetrics = snapshot(isScrolling)
|
||||
if (isScrolling) scheduleScrollEnd()
|
||||
return nextMetrics
|
||||
},
|
||||
[contentHeight, minThumbSize, scheduleScrollEnd, snapshot, trackRef, viewportRef]
|
||||
)
|
||||
|
||||
const api = useMemo<ScrollAreaApi>(
|
||||
() => ({
|
||||
scrollTo(top: number) {
|
||||
scrollToNow(top)
|
||||
},
|
||||
scrollBy(deltaTop: number) {
|
||||
const element = viewportRef.current
|
||||
if (!element) return
|
||||
scrollToNow(element.scrollTop + deltaTop)
|
||||
},
|
||||
scrollToTop() {
|
||||
scrollToNow(0)
|
||||
},
|
||||
scrollToBottom() {
|
||||
const element = viewportRef.current
|
||||
if (!element) return
|
||||
const metrics = measureScrollArea(
|
||||
element,
|
||||
trackRef.current,
|
||||
contentHeight,
|
||||
minThumbSize,
|
||||
true
|
||||
)
|
||||
scrollToNow(metrics.maxScrollTop)
|
||||
},
|
||||
getScrollElement() {
|
||||
return viewportRef.current
|
||||
},
|
||||
}),
|
||||
[contentHeight, minThumbSize, scrollToNow, trackRef, viewportRef]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current
|
||||
if (!element || defaultScrollTopAppliedRef.current) return
|
||||
|
||||
defaultScrollTopAppliedRef.current = true
|
||||
element.scrollTop = defaultScrollTop
|
||||
snapshot(false)
|
||||
}, [defaultScrollTop, snapshot, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof scrollTop !== "number") return
|
||||
const element = viewportRef.current
|
||||
if (!element || element.scrollTop === scrollTop) return
|
||||
element.scrollTop = scrollTop
|
||||
snapshot(false)
|
||||
}, [scrollTop, snapshot, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current
|
||||
if (!element) return
|
||||
|
||||
const handleScroll = () => {
|
||||
if (
|
||||
programmaticScrollTopRef.current !== null &&
|
||||
Math.abs(element.scrollTop - programmaticScrollTopRef.current) < 0.5
|
||||
) {
|
||||
programmaticScrollTopRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
snapshot(true)
|
||||
scheduleScrollEnd()
|
||||
}
|
||||
|
||||
element.addEventListener("scroll", handleScroll, { passive: true })
|
||||
return () => {
|
||||
element.removeEventListener("scroll", handleScroll)
|
||||
}
|
||||
}, [scheduleScrollEnd, snapshot, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current
|
||||
const track = trackRef.current
|
||||
if (!element || !track) return
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
onViewportResizeRef.current?.({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
})
|
||||
}
|
||||
snapshot(false)
|
||||
})
|
||||
|
||||
resizeObserver.observe(element)
|
||||
resizeObserver.observe(track)
|
||||
snapshot(false)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [snapshot, trackRef, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
snapshot(false)
|
||||
}, [contentHeight, snapshot])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
pendingNotificationRef.current = null
|
||||
notificationScheduledRef.current = false
|
||||
if (scrollEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollEndTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { state, api, metricsRef, snapshot, scrollToNow }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--vscode-widget-border);
|
||||
}
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Update } from "@tauri-apps/plugin-updater";
|
||||
import {
|
||||
checkForUpdate,
|
||||
currentAppVersion,
|
||||
hasNativeAppLifecycle,
|
||||
installUpdate,
|
||||
readAutostart,
|
||||
writeAutostart,
|
||||
} from "../../native/appLifecycle";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import { TitledCard } from "../ui/TitledCard";
|
||||
import { useMessage } from "../ui/message";
|
||||
import styles from "./AppLifecycleSettingsCard.module.scss";
|
||||
|
||||
export function AppLifecycleSettingsCard() {
|
||||
const message = useMessage();
|
||||
const native = hasNativeAppLifecycle();
|
||||
const updateRef = useRef<Update | null>(null);
|
||||
const [version, setVersion] = useState("…");
|
||||
const [autostart, setAutostart] = useState(false);
|
||||
const [loadingAutostart, setLoadingAutostart] = useState(native);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
void currentAppVersion().then((next) => { if (!disposed) setVersion(next); });
|
||||
if (native) {
|
||||
void readAutostart()
|
||||
.then((enabled) => { if (!disposed) setAutostart(enabled); })
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
|
||||
.finally(() => { if (!disposed) setLoadingAutostart(false); });
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
const update = updateRef.current;
|
||||
updateRef.current = null;
|
||||
if (update) void update.close();
|
||||
};
|
||||
}, [message, native]);
|
||||
|
||||
const toggleAutostart = async (enabled: boolean) => {
|
||||
try {
|
||||
setLoadingAutostart(true);
|
||||
await writeAutostart(enabled);
|
||||
setAutostart(await readAutostart());
|
||||
message(enabled ? t("已开启开机启动") : t("已关闭开机启动"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoadingAutostart(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkUpdate = async () => {
|
||||
try {
|
||||
setChecking(true);
|
||||
const previous = updateRef.current;
|
||||
updateRef.current = null;
|
||||
if (previous) await previous.close();
|
||||
const update = await checkForUpdate();
|
||||
updateRef.current = update;
|
||||
setAvailableVersion(update?.version ?? null);
|
||||
message(update ? t("发现新版本 {version}", { version: update.version }) : t("当前已是最新版本"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateNow = async () => {
|
||||
const update = updateRef.current;
|
||||
if (!update) return;
|
||||
try {
|
||||
setInstalling(true);
|
||||
await installUpdate(update);
|
||||
} catch (cause) {
|
||||
setInstalling(false);
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
};
|
||||
|
||||
return <TitledCard title={t("应用设置")}>
|
||||
<div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("开机启动")}</strong>
|
||||
<small>{t("登录系统后自动启动 Cursor BYOK。")}</small>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autostart}
|
||||
disabled={!native || loadingAutostart}
|
||||
label={t("开机启动")}
|
||||
onChange={(enabled) => void toggleAutostart(enabled)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("软件更新")}</strong>
|
||||
<small>{availableVersion
|
||||
? t("版本 {version} 可以安装", { version: availableVersion })
|
||||
: t("当前版本 {version}", { version })}</small>
|
||||
</div>
|
||||
{availableVersion
|
||||
? <Button size="small" variant="primary" disabled={installing} onClick={() => void updateNow()}>
|
||||
{installing ? t("安装中…") : t("下载并安装")}
|
||||
</Button>
|
||||
: <Button size="small" disabled={!native || checking} onClick={() => void checkUpdate()}>
|
||||
{checking ? t("检查中…") : t("检查更新")}
|
||||
</Button>}
|
||||
</div>
|
||||
</TitledCard>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.content,
|
||||
.customFields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.headerAction {
|
||||
padding: 4px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover { color: var(--vscode-textLink-activeForeground); }
|
||||
&:disabled { opacity: .5; }
|
||||
}
|
||||
|
||||
.actionGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.control { width: 68%; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ProxySettings, ProxySettingsInput } from "../../api";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { TextInput } from "../ui/FormControls";
|
||||
import { Select } from "../ui/Select";
|
||||
import { TitledCard } from "../ui/TitledCard";
|
||||
import styles from "./ProxySettingsCard.module.scss";
|
||||
|
||||
export function ProxySettingsCard({
|
||||
settings,
|
||||
draft,
|
||||
editing,
|
||||
saving,
|
||||
onDraftChange,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
settings: ProxySettings | null;
|
||||
draft: ProxySettingsInput;
|
||||
editing: boolean;
|
||||
saving: boolean;
|
||||
onDraftChange: (draft: ProxySettingsInput) => void;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const custom = draft.mode === "custom";
|
||||
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "system" ? t("使用系统代理") : t("自定义");
|
||||
const action = editing ? (
|
||||
<div className={styles.actionGroup}>
|
||||
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
|
||||
<Button variant="primary" size="small" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={styles.headerAction} disabled={!settings} onClick={onEdit}>
|
||||
{t("编辑")}
|
||||
</button>
|
||||
);
|
||||
|
||||
return <TitledCard title={t("代理设置")} action={action}>
|
||||
<div className={styles.content}>
|
||||
{editing ? <>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("代理方式")}</strong>
|
||||
<div className={styles.control}><Select ariaLabel={t("代理方式")} value={draft.mode} options={[{ value: "system", label: t("使用系统代理") }, { value: "custom", label: t("自定义") }]} onChange={(mode) => onDraftChange({ ...draft, mode: mode as ProxySettingsInput["mode"] })} /></div>
|
||||
</div>
|
||||
{custom && <div className={styles.customFields}>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("代理地址")}</strong>
|
||||
<div className={styles.control}><TextInput value={draft.address} placeholder="http://127.0.0.1:7890" onChange={(event) => onDraftChange({ ...draft, address: event.target.value })} /></div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("认证")}</strong>
|
||||
<Checkbox checked={draft.auth_enabled} label={t("代理需要认证")} onChange={(auth_enabled) => onDraftChange({ ...draft, auth_enabled })} />
|
||||
</div>
|
||||
{draft.auth_enabled && <div className={styles.customFields}>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("用户名")}</strong>
|
||||
<div className={styles.control}><TextInput value={draft.username} autoComplete="off" onChange={(event) => onDraftChange({ ...draft, username: event.target.value })} /></div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("密码")}</strong>
|
||||
<div className={styles.control}><TextInput type="password" value={draft.password ?? ""} autoComplete="new-password" placeholder={settings?.has_password ? t("留空表示保留当前密码") : ""} onChange={(event) => onDraftChange({ ...draft, password: event.target.value })} /></div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>}
|
||||
</> : <>
|
||||
<div className={styles.row}><strong>{t("代理方式")}</strong><span className={styles.value}>{settings ? modeLabel(settings.mode) : t("加载中…")}</span></div>
|
||||
{settings?.mode === "custom" && <div className={styles.customFields}>
|
||||
<div className={styles.row}><strong>{t("代理地址")}</strong><span className={styles.value}>{settings.address}</span></div>
|
||||
<div className={styles.row}><strong>{t("认证")}</strong><span className={styles.value}>{settings.auth_enabled ? t("已启用") : t("未启用")}</span></div>
|
||||
{settings.auth_enabled && <>
|
||||
<div className={styles.row}><strong>{t("用户名")}</strong><span className={styles.value}>{settings.username || "—"}</span></div>
|
||||
<div className={styles.row}><strong>{t("密码")}</strong><span className={styles.value}>{settings.has_password ? t("已设置") : t("未设置")}</span></div>
|
||||
</>}
|
||||
</div>}
|
||||
</>}
|
||||
</div>
|
||||
</TitledCard>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import controls from "./Controls.module.scss";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary";
|
||||
export type ButtonSize = "medium" | "small";
|
||||
|
||||
export function Button({
|
||||
variant = "secondary",
|
||||
size = "medium",
|
||||
className,
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type={type}
|
||||
className={[controls[variant], size === "small" && controls.small, className].filter(Boolean).join(" ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.root {
|
||||
padding: 0;
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-card-radius);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import styles from "./Card.module.scss";
|
||||
|
||||
type CardProps = {
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Card({ as: Component = "div", className, children }: CardProps) {
|
||||
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-xs;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:has(input:focus-visible) .control {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:has(input:checked) .control {
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border-color: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.control {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--vscode-checkbox-foreground, var(--vscode-button-foreground));
|
||||
background: var(--vscode-checkbox-background, var(--vscode-input-background));
|
||||
border: 1px solid var(--vscode-checkbox-border, var(--vscode-input-border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { checkIcon } from "./icons";
|
||||
import styles from "./Checkbox.module.scss";
|
||||
|
||||
export function Checkbox({ checked, disabled, label, onChange }: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return <label className={styles.root}>
|
||||
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
|
||||
<span className={styles.control} aria-hidden="true">{checked && <Icon icon={checkIcon} size="0.875em" />}</span>
|
||||
<span>{label}</span>
|
||||
</label>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.primary {
|
||||
min-height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 11px;
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
min-height: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 9px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
&:hover { background: var(--vscode-list-hoverBackground); }
|
||||
&:disabled { opacity: .5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.small {
|
||||
min-height: 24px;
|
||||
height: 24px;
|
||||
padding-inline: 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tableRegion {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-card-radius);
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableViewport {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tableContent {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.emptyViewport {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
inset: 33px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-base;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: type.$font-size-base;
|
||||
|
||||
th,
|
||||
td {
|
||||
max-width: 220px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
height: 33px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-editor-background);
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
}
|
||||
|
||||
.stickyRight {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
thead .stickyRight {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
tbody .stickyRight {
|
||||
background: var(--vscode-editorWidget-background);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ScrollableContent } from "../virtual/ScrollableContent";
|
||||
import { Pagination } from "./Pagination";
|
||||
import styles from "./DataTable.module.scss";
|
||||
|
||||
export type DataTableColumn<T> = {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
render: (row: T) => ReactNode;
|
||||
title?: (row: T) => string | undefined;
|
||||
className?: string;
|
||||
sticky?: "right";
|
||||
};
|
||||
|
||||
export function DataTable<T>({ rows, columns, rowKey, minWidth, emptyText = t("暂无数据") }: {
|
||||
rows: T[];
|
||||
columns: DataTableColumn<T>[];
|
||||
rowKey: (row: T) => string | number;
|
||||
minWidth?: number | string;
|
||||
emptyText?: ReactNode;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||
useEffect(() => { if (page > pageCount) setPage(pageCount); }, [page, pageCount]);
|
||||
const pageRows = useMemo(() => rows.slice((page - 1) * pageSize, page * pageSize), [page, pageSize, rows]);
|
||||
return <div className={styles.root}>
|
||||
<div className={styles.tableRegion}>
|
||||
<ScrollableContent horizontal className={[styles.viewport, rows.length === 0 ? styles.emptyViewport : ""].filter(Boolean).join(" ")} viewportClassName={styles.tableViewport} contentClassName={styles.tableContent} scrollbarInsetTop="33px">
|
||||
<table className={styles.table} style={{ minWidth }}>
|
||||
<thead><tr>{columns.map((column) => <th key={column.key} className={columnClass(column)}>{column.header}</th>)}</tr></thead>
|
||||
<tbody>{pageRows.map((row) => <tr key={rowKey(row)}>{columns.map((column) => <td key={column.key} className={columnClass(column)} title={column.title?.(row)}>{column.render(row)}</td>)}</tr>)}</tbody>
|
||||
</table>
|
||||
</ScrollableContent>
|
||||
{rows.length === 0 && <div className={styles.emptyState}>{emptyText}</div>}
|
||||
</div>
|
||||
<Pagination page={page} pageCount={pageCount} pageSize={pageSize} total={rows.length} onPageChange={setPage} onPageSizeChange={(nextPageSize) => { setPageSize(nextPageSize); setPage(1); }} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function columnClass<T>(column: DataTableColumn<T>) {
|
||||
return [column.className, column.sticky === "right" ? styles.stickyRight : ""].filter(Boolean).join(" ") || undefined;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 9px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 5px;
|
||||
outline: 0;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from "./Field.module.scss";
|
||||
|
||||
export function Field({ label, value, onChange, type = "text" }: { label: string; value: string; onChange: (value: string) => void; type?: string }) {
|
||||
return <label className={styles.root}><span>{label}</span><input type={type} value={value} onChange={(event) => onChange(event.target.value)} /></label>;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.field {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.label {
|
||||
min-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: help;
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { TooltipTrigger } from "./TooltipTrigger";
|
||||
import { informationOutlineIcon } from "./icons";
|
||||
import styles from "./FormControls.module.scss";
|
||||
|
||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={[styles.input, props.className].filter(Boolean).join(" ")} />;
|
||||
}
|
||||
|
||||
export function FormField({ label, hint, className, children }: { label: string; hint?: string; className?: string; children: React.ReactNode }) {
|
||||
return <label className={[styles.field, className].filter(Boolean).join(" ")}>
|
||||
<div className={styles.label}>
|
||||
<div>{label}</div>
|
||||
{hint && <TooltipTrigger label={hint}><div className={styles.hint}><Icon icon={informationOutlineIcon} size="1.1em" /></div></TooltipTrigger>}
|
||||
</div>
|
||||
{children}
|
||||
</label>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.icon {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
line-height: 1;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Icon as IconifyIcon, type IconifyIcon as IconData } from "@iconify/react/offline";
|
||||
import styles from "./Icon.module.scss";
|
||||
|
||||
export interface IconProps {
|
||||
icon?: IconData;
|
||||
src?: string;
|
||||
size?: `${number}em`;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Icon({ icon, src, size = "1em", className }: IconProps) {
|
||||
return <div
|
||||
aria-hidden="true"
|
||||
className={[styles.icon, className].filter(Boolean).join(" ")}
|
||||
style={{ height: size, width: size }}
|
||||
>
|
||||
{src ? <img alt="" src={src} /> : icon ? <IconifyIcon height="100%" icon={icon} width="100%" /> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
--json-editor-font-size: #{type.$font-size-2xs};
|
||||
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 5px;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.detail {
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 5px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border));
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, #000 22%, transparent);
|
||||
}
|
||||
|
||||
.formatButton {
|
||||
padding: 2px 4px;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
}
|
||||
|
||||
.host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type * as Monaco from "monaco-editor/editor/editor.api";
|
||||
import EditorWorker from "monaco-editor/editor/editor.worker?worker";
|
||||
import JsonWorker from "monaco-editor/language/json/json.worker?worker";
|
||||
import styles from "./JsonEditor.module.scss";
|
||||
|
||||
type MonacoApi = typeof Monaco;
|
||||
|
||||
let monacoPromise: Promise<MonacoApi> | null = null;
|
||||
|
||||
function loadMonaco() {
|
||||
if (!monacoPromise) {
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: (_moduleId, label) => label === "json" ? new JsonWorker() : new EditorWorker(),
|
||||
};
|
||||
monacoPromise = Promise.all([
|
||||
import("monaco-editor/editor/editor.api"),
|
||||
import("monaco-editor/language/json/monaco.contribution"),
|
||||
]).then(([monaco]) => monaco);
|
||||
}
|
||||
return monacoPromise;
|
||||
}
|
||||
|
||||
function json(value: string) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatStructuredText(value: string) {
|
||||
const whole = json(value);
|
||||
if (whole !== null) return whole;
|
||||
|
||||
let changed = false;
|
||||
const formatted = value.split("\n").map((line) => {
|
||||
const match = /^(\s*data:\s*)(.*)$/.exec(line);
|
||||
if (!match || match[2] === "[DONE]") return line;
|
||||
const payload = json(match[2]);
|
||||
if (payload === null) return line;
|
||||
changed = true;
|
||||
return `${match[1]}${payload}`;
|
||||
}).join("\n");
|
||||
return changed ? formatted : value;
|
||||
}
|
||||
|
||||
function hexByte(value: number) {
|
||||
return Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
function monacoColor(value: string, fallback: string) {
|
||||
const color = value.trim() || fallback;
|
||||
const hex = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(color);
|
||||
if (hex) {
|
||||
const digits = hex[1];
|
||||
return digits.length <= 4
|
||||
? `#${[...digits].map((digit) => digit.repeat(2)).join("")}`
|
||||
: `#${digits}`;
|
||||
}
|
||||
|
||||
const rgb = /^rgba?\((.*)\)$/i.exec(color);
|
||||
if (rgb) {
|
||||
const parts = rgb[1].replace(/\s*[,/]\s*/g, " ").trim().split(/\s+/);
|
||||
if (parts.length === 3 || parts.length === 4) {
|
||||
const channels = parts.slice(0, 3).map((part) => {
|
||||
const number = Number.parseFloat(part);
|
||||
return part.endsWith("%") ? number * 2.55 : number;
|
||||
});
|
||||
const alphaPart = parts[3];
|
||||
const alpha = alphaPart == null
|
||||
? 255
|
||||
: (alphaPart.endsWith("%") ? Number.parseFloat(alphaPart) / 100 : Number.parseFloat(alphaPart)) * 255;
|
||||
if ([...channels, alpha].every(Number.isFinite)) {
|
||||
return `#${channels.map(hexByte).join("")}${alpha === 255 ? "" : hexByte(alpha)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function themeColor(styles: CSSStyleDeclaration, name: string, fallback: string) {
|
||||
return monacoColor(styles.getPropertyValue(name), fallback);
|
||||
}
|
||||
|
||||
function applyEditorTheme(monaco: MonacoApi) {
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const light = document.documentElement.dataset.theme === "default-light";
|
||||
monaco.editor.defineTheme("cursor-byok", {
|
||||
base: light ? "vs" : "vs-dark",
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
"editor.background": themeColor(rootStyles, "--vscode-input-background", light ? "#ffffff" : "#222222"),
|
||||
"editor.foreground": themeColor(rootStyles, "--vscode-input-foreground", light ? "#202020" : "#ffffffe6"),
|
||||
"editorCursor.foreground": themeColor(rootStyles, "--vscode-editorCursor-foreground", light ? "#202020" : "#ffffff"),
|
||||
"editorLineNumber.foreground": themeColor(rootStyles, "--vscode-editorLineNumber-foreground", light ? "#999999" : "#ffffff40"),
|
||||
"editorLineNumber.activeForeground": themeColor(rootStyles, "--vscode-editorLineNumber-activeForeground", light ? "#202020" : "#ffffffe6"),
|
||||
"editor.selectionBackground": themeColor(rootStyles, "--vscode-editor-selectionBackground", light ? "#0069cc33" : "#49b0ff33"),
|
||||
},
|
||||
});
|
||||
monaco.editor.setTheme("cursor-byok");
|
||||
}
|
||||
|
||||
export function JsonEditor({ value, onChange, readOnly = false, autoFormat = true, detail = false, ariaLabel }: {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
autoFormat?: boolean;
|
||||
detail?: boolean;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
const modelRef = useRef<Monaco.editor.ITextModel | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const valueRef = useRef(value);
|
||||
const readOnlyRef = useRef(readOnly);
|
||||
const autoFormatRef = useRef(autoFormat);
|
||||
const ariaLabelRef = useRef(ariaLabel);
|
||||
const changingRef = useRef(false);
|
||||
onChangeRef.current = onChange;
|
||||
valueRef.current = value;
|
||||
readOnlyRef.current = readOnly;
|
||||
autoFormatRef.current = autoFormat;
|
||||
ariaLabelRef.current = ariaLabel;
|
||||
|
||||
const format = () => {
|
||||
const model = modelRef.current;
|
||||
if (!model) return;
|
||||
const formatted = formatStructuredText(model.getValue());
|
||||
if (formatted === model.getValue()) return;
|
||||
changingRef.current = true;
|
||||
model.setValue(formatted);
|
||||
changingRef.current = false;
|
||||
onChangeRef.current?.(formatted);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!host || !root) return;
|
||||
let disposed = false;
|
||||
let contentSubscription: Monaco.IDisposable | null = null;
|
||||
let blurSubscription: Monaco.IDisposable | null = null;
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
void loadMonaco().then((monaco) => {
|
||||
if (disposed) return;
|
||||
applyEditorTheme(monaco);
|
||||
const rootStyles = getComputedStyle(root);
|
||||
const fontSize = Number.parseFloat(rootStyles.getPropertyValue("--json-editor-font-size"));
|
||||
const fontFamily = rootStyles.getPropertyValue("--oa-code-font").trim();
|
||||
const initialValue = autoFormatRef.current ? formatStructuredText(valueRef.current) : valueRef.current;
|
||||
const model = monaco.editor.createModel(initialValue, "json");
|
||||
const editor = monaco.editor.create(host, {
|
||||
model,
|
||||
theme: "cursor-byok",
|
||||
ariaLabel: ariaLabelRef.current,
|
||||
readOnly: readOnlyRef.current,
|
||||
domReadOnly: readOnlyRef.current,
|
||||
automaticLayout: true,
|
||||
fontFamily: fontFamily || undefined,
|
||||
fontSize: Number.isFinite(fontSize) ? fontSize : undefined,
|
||||
minimap: { enabled: false },
|
||||
glyphMargin: false,
|
||||
folding: true,
|
||||
lineNumbersMinChars: 3,
|
||||
overviewRulerLanes: 0,
|
||||
overviewRulerBorder: false,
|
||||
renderLineHighlight: "none",
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
wordWrap: "off",
|
||||
padding: { top: 8, bottom: 16 },
|
||||
stickyScroll: { enabled: false },
|
||||
contextmenu: true,
|
||||
formatOnPaste: !readOnlyRef.current,
|
||||
formatOnType: !readOnlyRef.current,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
});
|
||||
editorRef.current = editor;
|
||||
modelRef.current = model;
|
||||
const remeasure = () => {
|
||||
if (disposed) return;
|
||||
monaco.editor.remeasureFonts();
|
||||
editor.layout();
|
||||
};
|
||||
requestAnimationFrame(remeasure);
|
||||
void document.fonts?.ready.then(remeasure);
|
||||
contentSubscription = model.onDidChangeContent(() => {
|
||||
if (!changingRef.current) onChangeRef.current?.(model.getValue());
|
||||
});
|
||||
blurSubscription = editor.onDidBlurEditorWidget(() => {
|
||||
if (!readOnlyRef.current && autoFormatRef.current) format();
|
||||
});
|
||||
themeObserver = new MutationObserver(() => applyEditorTheme(monaco));
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
themeObserver?.disconnect();
|
||||
blurSubscription?.dispose();
|
||||
contentSubscription?.dispose();
|
||||
editorRef.current?.dispose();
|
||||
modelRef.current?.dispose();
|
||||
editorRef.current = null;
|
||||
modelRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const model = modelRef.current;
|
||||
const next = readOnly && autoFormat ? formatStructuredText(value) : value;
|
||||
if (!model || model.getValue() === next) return;
|
||||
changingRef.current = true;
|
||||
model.setValue(next);
|
||||
changingRef.current = false;
|
||||
}, [autoFormat, readOnly, value]);
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.updateOptions({ readOnly, domReadOnly: readOnly, ariaLabel });
|
||||
}, [ariaLabel, readOnly]);
|
||||
|
||||
return <div ref={rootRef} className={[styles.root, detail && styles.detail].filter(Boolean).join(" ")}>
|
||||
<div className={styles.toolbar}>
|
||||
<button type="button" className={styles.formatButton} onClick={format}>{t("格式化")}</button>
|
||||
</div>
|
||||
<div ref={hostRef} className={styles.host} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.region {
|
||||
position: fixed;
|
||||
z-index: 11000;
|
||||
top: calc(48px + env(safe-area-inset-top));
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
padding: 10px 16px;
|
||||
color: var(--vscode-notifications-foreground);
|
||||
background: var(--vscode-notifications-background);
|
||||
border: 1px solid var(--vscode-notifications-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 38%);
|
||||
font-size: type.$font-size-base;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
pointer-events: auto;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
animation: enter 200ms ease;
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.leaving {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
@keyframes enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.message {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getMessageSnapshot, subscribeToMessages } from "./message";
|
||||
import styles from "./MessageProvider.module.scss";
|
||||
|
||||
export function MessageProvider() {
|
||||
const { current } = useSyncExternalStore(subscribeToMessages, getMessageSnapshot);
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.region} aria-live="polite" aria-atomic="true">
|
||||
{current && (
|
||||
<div key={current.id} className={`${styles.message} ${current.leaving ? styles.leaving : ""}`} role="status">
|
||||
{current.content}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.mask {
|
||||
position: fixed;
|
||||
z-index: 13000;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--window-header-height) 24px 24px;
|
||||
background: rgb(0 0 0 / 52%);
|
||||
}
|
||||
|
||||
.dragLayer {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: var(--window-header-height);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(620px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - var(--window-header-height) - 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: 0 20px 50px rgb(0 0 0 / 45%);
|
||||
|
||||
header {
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border);
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 630;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-top: 1px solid var(--vscode-editorWidget-border);
|
||||
}
|
||||
}
|
||||
|
||||
.wide {
|
||||
width: min(1080px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - var(--window-header-height) - 112px);
|
||||
}
|
||||
|
||||
.bodyViewport {
|
||||
height: auto;
|
||||
max-height: calc(100vh - var(--window-header-height) - 112px);
|
||||
}
|
||||
|
||||
.bodyContent {
|
||||
padding: 18px;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ScrollableContent } from "../virtual/ScrollableContent";
|
||||
import controls from "./Controls.module.scss";
|
||||
import styles from "./Modal.module.scss";
|
||||
|
||||
export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit, closeLabel = t("取消"), submitLabel = t("保存") }: { id?: string; open: boolean; title: string; children: ReactNode; busy?: boolean; wide?: boolean; onClose: () => void; onSubmit?: () => void; closeLabel?: string; submitLabel?: string }) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
const busyRef = useRef(Boolean(busy));
|
||||
closeRef.current = onClose;
|
||||
busyRef.current = Boolean(busy);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape" && !busyRef.current) closeRef.current(); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
requestAnimationFrame(() => dialog.current?.querySelector<HTMLElement>("input,button")?.focus());
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
if (previous && document.contains(previous)) previous.focus();
|
||||
};
|
||||
}, [open]);
|
||||
if (!open) return null;
|
||||
return createPortal(<div className={styles.mask}>
|
||||
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
|
||||
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role="dialog" aria-modal="true" aria-label={title}>
|
||||
<header>{title}</header>
|
||||
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
|
||||
<footer>
|
||||
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
|
||||
{onSubmit && <button type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
|
||||
</footer>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.target {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 5px;
|
||||
|
||||
&:focus-within,
|
||||
&[data-open] { border-color: var(--vscode-focusBorder); }
|
||||
}
|
||||
|
||||
.trigger {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.clear {
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
flex: 0 0 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
|
||||
}
|
||||
|
||||
.summary {
|
||||
overflow: hidden;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 14001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-list-hoverForeground, var(--vscode-dropdown-foreground));
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"] {
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"]:hover {
|
||||
background: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-activeSelectionBackground));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listFrame,
|
||||
.list {
|
||||
min-height: 32px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listFrame {
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.option {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
> span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.check { width: 1.1em; flex: 0 0 1.1em; }
|
||||
.empty { padding: 8px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--vscode-dropdown-border);
|
||||
|
||||
button {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
justify-content: center;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon, windowCloseIcon } from "./icons";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import styles from "./MultiSelect.module.scss";
|
||||
|
||||
export type MultiSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: IconProps["icon"];
|
||||
};
|
||||
|
||||
export function MultiSelect({ label, value, options, onChange }: {
|
||||
label: string;
|
||||
value: string[];
|
||||
options: MultiSelectOption[];
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const firstOption = useRef<HTMLButtonElement>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 220, maxHeight: 280 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
|
||||
padding: 10,
|
||||
apply: ({ rects, availableHeight }) => setPosition((current) => ({
|
||||
...current,
|
||||
width: rects.reference.width,
|
||||
maxHeight: Math.max(120, availableHeight),
|
||||
})),
|
||||
})],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
firstOption.current?.focus();
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!root.current?.contains(target) && !menu.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
}, [open]);
|
||||
|
||||
const selected = new Set(value);
|
||||
const summary = value.length === 0 ? t("全部") : t("已选 {count} 项", { count: value.length });
|
||||
const toggle = (option: string) => onChange(selected.has(option)
|
||||
? value.filter((item) => item !== option)
|
||||
: [...value, option]);
|
||||
|
||||
const close = () => { setOpen(false); trigger.current?.focus(); };
|
||||
const openMenu = () => {
|
||||
const width = root.current?.getBoundingClientRect().width;
|
||||
if (width) setPosition((current) => ({ ...current, width }));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return <>
|
||||
<div ref={root} className={styles.target} data-open={open || undefined}>
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label={t("筛选项:{label},{summary}", { label, summary })}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
onClick={() => { if (open) close(); else openMenu(); }}
|
||||
onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); openMenu(); } }}
|
||||
>
|
||||
<span>{label}</span><span className={styles.summary} aria-hidden="true">{summary}</span><Icon icon={chevronDownIcon} size="1.1em" />
|
||||
</button>
|
||||
{value.length > 0 && <button type="button" className={styles.clear} aria-label={t("清除{label}筛选", { label })} onClick={() => onChange([])}>
|
||||
<Icon icon={windowCloseIcon} size="1.1em" />
|
||||
</button>}
|
||||
</div>
|
||||
{open && createPortal(<div
|
||||
id={menuId}
|
||||
ref={menu}
|
||||
className={styles.menu}
|
||||
aria-label={label}
|
||||
style={position}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }}
|
||||
>
|
||||
<div
|
||||
className={styles.listFrame}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
aria-multiselectable="true"
|
||||
style={{ height: Math.min(options.length * 32, Math.max(32, position.maxHeight - 45)) }}
|
||||
><VirtualList
|
||||
items={options}
|
||||
itemKey="value"
|
||||
className={styles.list}
|
||||
style={{ height: "100%" }}
|
||||
estimatedItemHeight={32}
|
||||
overscan={4}
|
||||
empty={<div className={styles.empty}>{t("暂无选项")}</div>}
|
||||
>{(option, index) => <button
|
||||
ref={index === 0 ? firstOption : undefined}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-label={option.label}
|
||||
aria-selected={selected.has(option.value)}
|
||||
onClick={() => toggle(option.value)}
|
||||
>
|
||||
<span className={styles.option}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span>
|
||||
<span className={styles.check} aria-hidden="true">{selected.has(option.value) && <Icon icon={checkIcon} size="1.1em" />}</span>
|
||||
</button>}</VirtualList></div>
|
||||
{options.length > 0 && <div className={styles.footer}>
|
||||
<button type="button" onClick={() => onChange(options.map((option) => option.value))}>{t("全选")}</button>
|
||||
<button type="button" onClick={() => onChange([])}>{t("全不选")}</button>
|
||||
</div>}
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
flex: 0 0 auto;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 0px 0 6px 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
> button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.pageSize {
|
||||
width: 92px;
|
||||
margin-right: 4px;
|
||||
|
||||
> button {
|
||||
height: 26px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import controls from "./Controls.module.scss";
|
||||
import { Icon } from "./Icon";
|
||||
import { chevronDoubleLeftIcon, chevronDoubleRightIcon, chevronLeftIcon, chevronRightIcon } from "./icons";
|
||||
import { Select } from "./Select";
|
||||
import { TooltipTrigger } from "./TooltipTrigger";
|
||||
import styles from "./Pagination.module.scss";
|
||||
|
||||
export function Pagination({ page, pageCount, pageSize, total, pageSizes = [20, 50, 100], onPageChange, onPageSizeChange }: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
pageSizes?: number[];
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
}) {
|
||||
const disabledPrevious = page <= 1;
|
||||
const disabledNext = page >= pageCount;
|
||||
return <div className={styles.root}>
|
||||
<span>{t("共 {count} 条", { count: total })}</span>
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.pageSize}><Select ariaLabel={t("每页条数")} value={String(pageSize)} options={pageSizes.map((size) => ({ value: String(size), label: t("{count} 条/页", { count: size }) }))} onChange={(value) => onPageSizeChange(Number(value))} /></div>
|
||||
<span>{t("第 {page} / {count} 页", { page, count: pageCount })}</span>
|
||||
<TooltipTrigger label={t("第一页")}><button className={controls.iconButton} aria-label={t("第一页")} disabled={disabledPrevious} onClick={() => onPageChange(1)}><Icon icon={chevronDoubleLeftIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("上一页")}><button className={controls.iconButton} aria-label={t("上一页")} disabled={disabledPrevious} onClick={() => onPageChange(page - 1)}><Icon icon={chevronLeftIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("下一页")}><button className={controls.iconButton} aria-label={t("下一页")} disabled={disabledNext} onClick={() => onPageChange(page + 1)}><Icon icon={chevronRightIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("最后一页")}><button className={controls.iconButton} aria-label={t("最后一页")} disabled={disabledNext} onClick={() => onPageChange(pageCount)}><Icon icon={chevronDoubleRightIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.trigger,
|
||||
.combo input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
font-size: type.$font-size-xs;
|
||||
&:focus {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.optionContent {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
|
||||
> :last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.dropdownIcon {
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
.dropdownIconOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.comboRow {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.combo {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-input-background);
|
||||
&:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.multiCombo {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus-within,
|
||||
&[data-open] {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.multiValues {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
|
||||
> input {
|
||||
min-width: 120px;
|
||||
min-height: 24px;
|
||||
flex: 1;
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.multiCount {
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.combo input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.comboToggle {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
align-self: center;
|
||||
padding: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
appearance: none;
|
||||
font-size: type.$font-size-base;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 14000;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
display: block;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
&:hover,
|
||||
&[data-active],
|
||||
&[aria-selected="true"] {
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu .multiOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
> span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import type { VirtualListApi } from "../virtual/virtualTypes";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon } from "./icons";
|
||||
import styles from "./Select.module.scss";
|
||||
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"] };
|
||||
|
||||
export function Select({ value, options, disabled, ariaLabel, onChange }: { value: string; options: SelectOption[]; disabled?: boolean; ariaLabel: string; onChange: (value: string) => void }) {
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const selected = options.find((option) => option.value === value);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !button.current || !menu.current) return;
|
||||
return autoUpdate(button.current, menu.current, () => void computePosition(button.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({ padding: 10, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(120, availableHeight) })) })],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (open && active >= 0) listApi.current?.scrollToIndex(active);
|
||||
}, [active, open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => { if (!button.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); };
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
|
||||
const move = (step: number) => {
|
||||
if (!options.length) return;
|
||||
setOpen(true);
|
||||
setActive((index) => (index + step + options.length) % options.length);
|
||||
};
|
||||
const choose = (option: SelectOption) => { onChange(option.value); setOpen(false); button.current?.focus(); };
|
||||
return <>
|
||||
<button ref={button} type="button" className={styles.trigger} aria-label={ariaLabel} aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} disabled={disabled} onClick={() => { const nextOpen = !open; setOpen(nextOpen); if (nextOpen) setActive(Math.max(0, options.findIndex((option) => option.value === value))); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter" && open) { event.preventDefault(); choose(options[active]); }
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}}><span className={styles.optionContent}>{selected?.icon && <Icon icon={selected.icon} />}<span>{selected?.label ?? value}</span></span><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" style={{ left: position.left, top: position.top, width: position.width }}>
|
||||
<VirtualList items={options} itemKey="value" estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(options.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" role="option" aria-selected={option.value === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(option)}><span className={styles.optionContent}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span></button>}
|
||||
</VirtualList>
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function Combobox({ value, options = [], placeholder, disabled, append, onChange }: { value: string; options?: string[]; placeholder?: string; disabled?: boolean; append?: ReactNode; onChange: (value: string) => void }) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const filtered = useMemo(() => {
|
||||
const query = value.trim().toLocaleLowerCase();
|
||||
return options.filter((option) => !query || option.toLocaleLowerCase().includes(query));
|
||||
}, [options, value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(6), flip({ padding: 12 }), shift({ padding: 12 }), size({ padding: 12, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(160, availableHeight) })) })],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open, filtered.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); };
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (open && active >= 0) listApi.current?.scrollToIndex(active);
|
||||
}, [active, open]);
|
||||
|
||||
const openMenu = () => {
|
||||
if (disabled || options.length === 0) return;
|
||||
setOpen(true);
|
||||
const selected = filtered.indexOf(value);
|
||||
setActive(selected >= 0 ? selected : 0);
|
||||
};
|
||||
const choose = (option: string) => { onChange(option); setOpen(false); input.current?.focus(); };
|
||||
const move = (step: number) => {
|
||||
if (!open) { openMenu(); return; }
|
||||
if (!filtered.length) return;
|
||||
setActive((index) => (index < 0 ? 0 : (index + step + filtered.length) % filtered.length));
|
||||
};
|
||||
return <div className={styles.comboRow}><div ref={root} className={styles.combo}>
|
||||
<input ref={input} value={value} placeholder={placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) openMenu(); }} onBlur={() => window.setTimeout(() => setOpen(false), 100)} onChange={(event) => { onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter" && open && filtered[active]) { event.preventDefault(); choose(filtered[active]); }
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}} />
|
||||
<button type="button" className={styles.comboToggle} disabled={disabled} aria-label={t("打开模型列表")} aria-expanded={open} tabIndex={-1} onMouseDown={(event) => event.preventDefault()} onClick={() => { if (open) setOpen(false); else { openMenu(); input.current?.focus(); } }}><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" style={{ left: position.left, top: position.top, width: position.width }}>
|
||||
<VirtualList items={filtered} itemKey={(option) => option} estimatedItemHeight={30} onReady={(api) => { listApi.current = api; if (active >= 0) api.scrollToIndex(active); }} style={{ height: Math.min(filtered.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" role="option" aria-selected={option === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => choose(option)}>{option}</button>}
|
||||
</VirtualList>
|
||||
</div>, document.body)}
|
||||
</div>{append}</div>;
|
||||
}
|
||||
|
||||
export function MultiCombobox({ value, options = [], placeholder, disabled, append, onChange }: {
|
||||
value: string[];
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
append?: ReactNode;
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const menuId = useId();
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
return options.filter((option) => !normalized || option.toLocaleLowerCase().includes(normalized));
|
||||
}, [options, query]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(6), flip({ padding: 12 }), shift({ padding: 12 }), size({ padding: 12, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(160, availableHeight) })) })],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [filtered.length, open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (open && active >= 0) listApi.current?.scrollToIndex(active);
|
||||
}, [active, open]);
|
||||
|
||||
const add = (modelId: string) => {
|
||||
const normalized = modelId.trim();
|
||||
if (normalized && !selected.has(normalized)) onChange([...value, normalized]);
|
||||
setQuery("");
|
||||
};
|
||||
const toggle = (modelId: string) => {
|
||||
onChange(selected.has(modelId) ? value.filter((item) => item !== modelId) : [...value, modelId]);
|
||||
setQuery("");
|
||||
input.current?.focus();
|
||||
};
|
||||
const move = (step: number) => {
|
||||
if (!filtered.length) return;
|
||||
setOpen(true);
|
||||
setActive((index) => (index + step + filtered.length) % filtered.length);
|
||||
};
|
||||
|
||||
return <div className={styles.comboRow}><div ref={root} className={styles.multiCombo} data-open={open || undefined}>
|
||||
<div className={styles.multiValues}>
|
||||
{value.length > 0 && <span className={styles.multiCount}>{t("已选择 {count} 个", { count: value.length })}</span>}
|
||||
<input ref={input} value={query} placeholder={value.length ? t("继续选择或输入") : placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) setOpen(true); }} onChange={(event) => { setQuery(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const normalized = query.trim();
|
||||
const exact = filtered.find((option) => option.toLocaleLowerCase() === normalized.toLocaleLowerCase());
|
||||
if (normalized && !exact) add(normalized);
|
||||
else if (open && filtered[active]) toggle(filtered[active]);
|
||||
}
|
||||
if (event.key === "Backspace" && !query && value.length) onChange(value.slice(0, -1));
|
||||
if (event.key === "Escape") { setOpen(false); input.current?.focus(); }
|
||||
}} />
|
||||
</div>
|
||||
<button type="button" className={styles.comboToggle} disabled={disabled || !options.length} aria-label={t("打开模型列表")} aria-expanded={open} tabIndex={-1} onMouseDown={(event) => event.preventDefault()} onClick={() => { setOpen(!open); input.current?.focus(); }}><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" aria-multiselectable="true" style={{ left: position.left, top: position.top, width: position.width }} onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); input.current?.focus(); } }}>
|
||||
{filtered.length ? <VirtualList items={filtered} itemKey={(option) => option} estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(filtered.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" className={styles.multiOption} role="option" aria-selected={selected.has(option)} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => toggle(option)}><span>{option}</span>{selected.has(option) && <Icon icon={checkIcon} size="1.1em" />}</button>}
|
||||
</VirtualList> : <div className={styles.empty}>{t("输入模型名称后按 Enter 添加")}</div>}
|
||||
</div>, document.body)}
|
||||
</div>{append}</div>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.root {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 22%, transparent);
|
||||
transition: background 160ms ease;
|
||||
|
||||
&[data-checked] { background: var(--vscode-button-background); }
|
||||
&:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; }
|
||||
&:disabled { opacity: 0.5; }
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--vscode-button-foreground);
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 28%);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
&[data-checked] span { transform: translateX(16px); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import styles from "./Switch.module.scss";
|
||||
|
||||
export function Switch({ checked, disabled, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||
return <button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className={styles.root}
|
||||
data-checked={checked || undefined}
|
||||
onClick={() => onChange(!checked)}
|
||||
><span /></button>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-base;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"] {
|
||||
color: var(--vscode-foreground);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: -1px;
|
||||
left: 8px;
|
||||
height: 2px;
|
||||
content: "";
|
||||
background: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding-top: 16px;
|
||||
}
|
||||