重构搜索和筛选,采用rust高效构建二进制数据,wasm高效解析,搜索支持内联建议,优化导航栏样式,解析地图数据采用wasm,地图动态导入优化代码块样式实现解析mermaid
This commit is contained in:
parent
8656f037bd
commit
c59f4a1d24
4
.gitignore
vendored
4
.gitignore
vendored
@ -23,4 +23,8 @@ pnpm-debug.log*
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
# wasm
|
||||
**/target/*
|
||||
**/pkg/*
|
||||
|
||||
.vercel
|
||||
|
@ -11,12 +11,10 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import swup from "@swup/astro"
|
||||
import { SITE_URL } from "./src/consts";
|
||||
import pagefind from "astro-pagefind";
|
||||
import compressor from "astro-compressor";
|
||||
import vercel from "@astrojs/vercel";
|
||||
import expressiveCode from "astro-expressive-code";
|
||||
import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers";
|
||||
import { pluginCollapsibleSections } from "@expressive-code/plugin-collapsible-sections";
|
||||
import { articleIndexerIntegration } from "./src/plugins/build-article-index.js";
|
||||
import customCodeBlocksIntegration from "./src/plugins/custom-code-blocks.js";
|
||||
|
||||
function getArticleDate(articleId) {
|
||||
try {
|
||||
@ -54,55 +52,21 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
integrations: [
|
||||
expressiveCode({
|
||||
themes: ['github-light', 'dracula'],
|
||||
themeCssSelector: (theme) =>
|
||||
theme.name === 'dracula' ? '[data-theme=dark]' : '[data-theme=light]',
|
||||
useDarkModeMediaQuery: false,
|
||||
|
||||
plugins: [
|
||||
pluginLineNumbers(),
|
||||
pluginCollapsibleSections(),
|
||||
],
|
||||
defaultProps: {
|
||||
showLineNumbers: true,
|
||||
collapseStyle: 'collapsible-auto',
|
||||
wrap: true,
|
||||
preserveIndent: true,
|
||||
hangingIndent: 2,
|
||||
},
|
||||
frames: {
|
||||
extractFileNameFromCode: true,
|
||||
},
|
||||
styleOverrides: {
|
||||
// 核心样式设置
|
||||
borderRadius: '0.5rem',
|
||||
borderWidth: '0.5px',
|
||||
codeFontSize: '0.9rem',
|
||||
codeFontFamily: "'JetBrains Mono', Menlo, Monaco, Consolas, 'Courier New', monospace",
|
||||
codeLineHeight: '1.5',
|
||||
codePaddingInline: '1.5rem',
|
||||
codePaddingBlock: '1.2rem',
|
||||
// 框架样式设置
|
||||
frames: {
|
||||
shadowColor: 'rgba(0, 0, 0, 0.12)',
|
||||
editorActiveTabBackground: '#ffffff',
|
||||
editorTabBarBackground: '#f5f5f5',
|
||||
terminalBackground: '#1a1a1a',
|
||||
terminalTitlebarBackground: '#333333',
|
||||
},
|
||||
// 文本标记样式
|
||||
textMarkers: {
|
||||
defaultChroma: 'rgba(255, 255, 0, 0.2)',
|
||||
},
|
||||
},
|
||||
}),
|
||||
// 使用我们自己的代码块集成替代expressiveCode
|
||||
customCodeBlocksIntegration(),
|
||||
|
||||
// MDX 集成配置
|
||||
mdx(),
|
||||
swup(),
|
||||
swup({
|
||||
cache: true,
|
||||
preload: true,
|
||||
}
|
||||
|
||||
),
|
||||
react(),
|
||||
pagefind(),
|
||||
// 使用我们自己的文章索引生成器(替换pagefind)
|
||||
articleIndexerIntegration(),
|
||||
|
||||
sitemap({
|
||||
filter: (page) => !page.includes("/api/"),
|
||||
serialize(item) {
|
||||
@ -147,7 +111,7 @@ export default defineConfig({
|
||||
|
||||
// Markdown 配置
|
||||
markdown: {
|
||||
syntaxHighlight: false, // 禁用默认的语法高亮,使用expressiveCode代替
|
||||
syntaxHighlight: false, // 禁用默认的语法高亮,使用我们自定义的高亮
|
||||
remarkPlugins: [
|
||||
[remarkEmoji, { emoticon: false, padded: true }]
|
||||
],
|
||||
|
4394
package-lock.json
generated
4394
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@ -9,30 +9,32 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.2.4",
|
||||
"@astrojs/node": "^9.2.0",
|
||||
"@astrojs/react": "^4.2.4",
|
||||
"@astrojs/sitemap": "^3.3.0",
|
||||
"@astrojs/vercel": "^8.1.3",
|
||||
"@astrojs/mdx": "^4.2.5",
|
||||
"@astrojs/node": "^9.2.1",
|
||||
"@astrojs/react": "^4.2.5",
|
||||
"@astrojs/sitemap": "^3.3.1",
|
||||
"@astrojs/vercel": "^8.1.4",
|
||||
"@astrolib/seo": "^1.0.0-beta.8",
|
||||
"@expressive-code/plugin-collapsible-sections": "^0.41.2",
|
||||
"@expressive-code/plugin-line-numbers": "^0.41.2",
|
||||
"@mermaid-js/mermaid-cli": "^11.4.2",
|
||||
"@swup/astro": "^1.6.0",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/three": "^0.174.0",
|
||||
"astro": "^5.7.4",
|
||||
"@types/three": "^0.176.0",
|
||||
"astro": "^5.7.5",
|
||||
"astro-expressive-code": "^0.41.2",
|
||||
"astro-pagefind": "^1.8.3",
|
||||
"cheerio": "^1.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"octokit": "^3.2.1",
|
||||
"octokit": "^4.1.3",
|
||||
"puppeteer": "^23.11.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-masonry-css": "^1.0.16",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"three": "^0.174.0"
|
||||
"three": "^0.176.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
|
BIN
src/assets/article-index/article-indexer-cli
Normal file
BIN
src/assets/article-index/article-indexer-cli
Normal file
Binary file not shown.
BIN
src/assets/article-index/article-indexer-cli.exe
Normal file
BIN
src/assets/article-index/article-indexer-cli.exe
Normal file
Binary file not shown.
362
src/assets/wasm/article-filter/article_filter.js
Normal file
362
src/assets/wasm/article-filter/article_filter.js
Normal file
@ -0,0 +1,362 @@
|
||||
let wasm;
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
const cachedTextEncoder = (typeof TextEncoder !== 'undefined' ? new TextEncoder('utf-8') : { encode: () => { throw Error('TextEncoder not available') } } );
|
||||
|
||||
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
|
||||
? function (arg, view) {
|
||||
return cachedTextEncoder.encodeInto(arg, view);
|
||||
}
|
||||
: function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
});
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = encodeString(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
|
||||
|
||||
if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); };
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
/**
|
||||
* 初始化函数 - 设置错误处理
|
||||
*/
|
||||
export function start() {
|
||||
wasm.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本信息
|
||||
* @returns {string}
|
||||
*/
|
||||
export function version() {
|
||||
let deferred1_0;
|
||||
let deferred1_1;
|
||||
try {
|
||||
const ret = wasm.version();
|
||||
deferred1_0 = ret[0];
|
||||
deferred1_1 = ret[1];
|
||||
return getStringFromWasm0(ret[0], ret[1]);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred1_0, deferred1_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function passArray8ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_export_3.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
const ArticleFilterJSFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_articlefilterjs_free(ptr >>> 0, 1));
|
||||
/**
|
||||
* 文章过滤器JS接口 - 提供给JavaScript使用的筛选API
|
||||
*/
|
||||
export class ArticleFilterJS {
|
||||
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
ArticleFilterJSFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_articlefilterjs_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* 初始化过滤器并加载索引
|
||||
* @param {Uint8Array} index_data
|
||||
*/
|
||||
static init(index_data) {
|
||||
const ptr0 = passArray8ToWasm0(index_data, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.articlefilterjs_init(ptr0, len0);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取所有标签
|
||||
* @returns {any}
|
||||
*/
|
||||
static get_all_tags() {
|
||||
const ret = wasm.articlefilterjs_get_all_tags();
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
/**
|
||||
* 筛选文章
|
||||
* @param {string} params_json
|
||||
* @returns {any}
|
||||
*/
|
||||
static filter_articles(params_json) {
|
||||
const ptr0 = passStringToWasm0(params_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.articlefilterjs_filter_articles(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
|
||||
} catch (e) {
|
||||
if (module.headers.get('Content-Type') != 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function __wbg_get_imports() {
|
||||
const imports = {};
|
||||
imports.wbg = {};
|
||||
imports.wbg.__wbg_String_8f0eb39a4a4c2f66 = function(arg0, arg1) {
|
||||
const ret = String(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbg_error_7534b8e9a36f1ab4 = function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
};
|
||||
imports.wbg.__wbg_log_c222819a41e063d3 = function(arg0) {
|
||||
console.log(arg0);
|
||||
};
|
||||
imports.wbg.__wbg_new_405e22f390576ce2 = function() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new_78feb108b6472713 = function() {
|
||||
const ret = new Array();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new_8a6f238a6ece86ea = function() {
|
||||
const ret = new Error();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_set_37837023f3d740e8 = function(arg0, arg1, arg2) {
|
||||
arg0[arg1 >>> 0] = arg2;
|
||||
};
|
||||
imports.wbg.__wbg_set_3f1d0b984ed272ed = function(arg0, arg1, arg2) {
|
||||
arg0[arg1] = arg2;
|
||||
};
|
||||
imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) {
|
||||
const ret = arg1.stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbindgen_bigint_from_u64 = function(arg0) {
|
||||
const ret = BigInt.asUintN(64, arg0);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_error_new = function(arg0, arg1) {
|
||||
const ret = new Error(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_init_externref_table = function() {
|
||||
const table = wasm.__wbindgen_export_3;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
;
|
||||
};
|
||||
imports.wbg.__wbindgen_number_new = function(arg0) {
|
||||
const ret = arg0;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_string_new = function(arg0, arg1) {
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_throw = function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
};
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
function __wbg_init_memory(imports, memory) {
|
||||
|
||||
}
|
||||
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasm = instance.exports;
|
||||
__wbg_init.__wbindgen_wasm_module = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
|
||||
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (typeof module !== 'undefined') {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
__wbg_init_memory(imports);
|
||||
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (typeof module_or_path !== 'undefined') {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module_or_path === 'undefined') {
|
||||
module_or_path = new URL('article_filter_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
__wbg_init_memory(imports);
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync };
|
||||
export default __wbg_init;
|
BIN
src/assets/wasm/article-filter/article_filter_bg.wasm
Normal file
BIN
src/assets/wasm/article-filter/article_filter_bg.wasm
Normal file
Binary file not shown.
561
src/assets/wasm/geo/geo_wasm.js
Normal file
561
src/assets/wasm/geo/geo_wasm.js
Normal file
@ -0,0 +1,561 @@
|
||||
let wasm;
|
||||
|
||||
const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
|
||||
|
||||
if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); };
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
const cachedTextEncoder = (typeof TextEncoder !== 'undefined' ? new TextEncoder('utf-8') : { encode: () => { throw Error('TextEncoder not available') } } );
|
||||
|
||||
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
|
||||
? function (arg, view) {
|
||||
return cachedTextEncoder.encodeInto(arg, view);
|
||||
}
|
||||
: function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
});
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = encodeString(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
export function start() {
|
||||
wasm.start();
|
||||
}
|
||||
|
||||
function _assertClass(instance, klass) {
|
||||
if (!(instance instanceof klass)) {
|
||||
throw new Error(`expected instance of ${klass.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_export_3.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
const BoundingBoxFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_boundingbox_free(ptr >>> 0, 1));
|
||||
|
||||
export class BoundingBox {
|
||||
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
BoundingBoxFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_boundingbox_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get min_x() {
|
||||
const ret = wasm.__wbg_get_boundingbox_min_x(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set min_x(arg0) {
|
||||
wasm.__wbg_set_boundingbox_min_x(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get min_y() {
|
||||
const ret = wasm.__wbg_get_boundingbox_min_y(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set min_y(arg0) {
|
||||
wasm.__wbg_set_boundingbox_min_y(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get min_z() {
|
||||
const ret = wasm.__wbg_get_boundingbox_min_z(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set min_z(arg0) {
|
||||
wasm.__wbg_set_boundingbox_min_z(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get max_x() {
|
||||
const ret = wasm.__wbg_get_boundingbox_max_x(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set max_x(arg0) {
|
||||
wasm.__wbg_set_boundingbox_max_x(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get max_y() {
|
||||
const ret = wasm.__wbg_get_boundingbox_max_y(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set max_y(arg0) {
|
||||
wasm.__wbg_set_boundingbox_max_y(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get max_z() {
|
||||
const ret = wasm.__wbg_get_boundingbox_max_z(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set max_z(arg0) {
|
||||
wasm.__wbg_set_boundingbox_max_z(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* @param {number} min_x
|
||||
* @param {number} min_y
|
||||
* @param {number} min_z
|
||||
* @param {number} max_x
|
||||
* @param {number} max_y
|
||||
* @param {number} max_z
|
||||
*/
|
||||
constructor(min_x, min_y, min_z, max_x, max_y, max_z) {
|
||||
const ret = wasm.boundingbox_new(min_x, min_y, min_z, max_x, max_y, max_z);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
BoundingBoxFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* @param {Vector3} point
|
||||
* @returns {number}
|
||||
*/
|
||||
distance_to_point(point) {
|
||||
_assertClass(point, Vector3);
|
||||
const ret = wasm.boundingbox_distance_to_point(this.__wbg_ptr, point.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get_size() {
|
||||
const ret = wasm.boundingbox_get_size(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
const GeoProcessorFinalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_geoprocessor_free(ptr >>> 0, 1));
|
||||
|
||||
export class GeoProcessor {
|
||||
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
GeoProcessorFinalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_geoprocessor_free(ptr, 0);
|
||||
}
|
||||
constructor() {
|
||||
const ret = wasm.geoprocessor_new();
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
GeoProcessorFinalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {number} radius
|
||||
* @returns {Vector3}
|
||||
*/
|
||||
lat_long_to_vector3(lat, lon, radius) {
|
||||
const ret = wasm.geoprocessor_lat_long_to_vector3(this.__wbg_ptr, lat, lon, radius);
|
||||
return Vector3.__wrap(ret);
|
||||
}
|
||||
/**
|
||||
* @param {string} world_json
|
||||
* @param {string} china_json
|
||||
* @param {string} visited_places_json
|
||||
* @param {number} scale
|
||||
*/
|
||||
process_geojson(world_json, china_json, visited_places_json, scale) {
|
||||
const ptr0 = passStringToWasm0(world_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(china_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ptr2 = passStringToWasm0(visited_places_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len2 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.geoprocessor_process_geojson(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2, scale);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {number} point_x
|
||||
* @param {number} point_y
|
||||
* @param {number} point_z
|
||||
* @param {number} _radius
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
find_nearest_country(point_x, point_y, point_z, _radius) {
|
||||
const ret = wasm.geoprocessor_find_nearest_country(this.__wbg_ptr, point_x, point_y, point_z, _radius);
|
||||
let v1;
|
||||
if (ret[0] !== 0) {
|
||||
v1 = getStringFromWasm0(ret[0], ret[1]).slice();
|
||||
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
|
||||
}
|
||||
return v1;
|
||||
}
|
||||
/**
|
||||
* @returns {any}
|
||||
*/
|
||||
get_boundary_lines() {
|
||||
const ret = wasm.geoprocessor_get_boundary_lines(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @returns {any}
|
||||
*/
|
||||
get_regions() {
|
||||
const ret = wasm.geoprocessor_get_regions(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
const Vector3Finalization = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(ptr => wasm.__wbg_vector3_free(ptr >>> 0, 1));
|
||||
|
||||
export class Vector3 {
|
||||
|
||||
static __wrap(ptr) {
|
||||
ptr = ptr >>> 0;
|
||||
const obj = Object.create(Vector3.prototype);
|
||||
obj.__wbg_ptr = ptr;
|
||||
Vector3Finalization.register(obj, obj.__wbg_ptr, obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
__destroy_into_raw() {
|
||||
const ptr = this.__wbg_ptr;
|
||||
this.__wbg_ptr = 0;
|
||||
Vector3Finalization.unregister(this);
|
||||
return ptr;
|
||||
}
|
||||
|
||||
free() {
|
||||
const ptr = this.__destroy_into_raw();
|
||||
wasm.__wbg_vector3_free(ptr, 0);
|
||||
}
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get x() {
|
||||
const ret = wasm.__wbg_get_boundingbox_min_x(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set x(arg0) {
|
||||
wasm.__wbg_set_boundingbox_min_x(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get y() {
|
||||
const ret = wasm.__wbg_get_boundingbox_min_y(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set y(arg0) {
|
||||
wasm.__wbg_set_boundingbox_min_y(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get z() {
|
||||
const ret = wasm.__wbg_get_boundingbox_min_z(this.__wbg_ptr);
|
||||
return ret;
|
||||
}
|
||||
/**
|
||||
* @param {number} arg0
|
||||
*/
|
||||
set z(arg0) {
|
||||
wasm.__wbg_set_boundingbox_min_z(this.__wbg_ptr, arg0);
|
||||
}
|
||||
/**
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} z
|
||||
*/
|
||||
constructor(x, y, z) {
|
||||
const ret = wasm.vector3_new(x, y, z);
|
||||
this.__wbg_ptr = ret >>> 0;
|
||||
Vector3Finalization.register(this, this.__wbg_ptr, this);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
|
||||
} catch (e) {
|
||||
if (module.headers.get('Content-Type') != 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function __wbg_get_imports() {
|
||||
const imports = {};
|
||||
imports.wbg = {};
|
||||
imports.wbg.__wbg_error_7534b8e9a36f1ab4 = function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
};
|
||||
imports.wbg.__wbg_new_405e22f390576ce2 = function() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new_5e0be73521bc8c17 = function() {
|
||||
const ret = new Map();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new_78feb108b6472713 = function() {
|
||||
const ret = new Array();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new_8a6f238a6ece86ea = function() {
|
||||
const ret = new Error();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_set_37837023f3d740e8 = function(arg0, arg1, arg2) {
|
||||
arg0[arg1 >>> 0] = arg2;
|
||||
};
|
||||
imports.wbg.__wbg_set_3f1d0b984ed272ed = function(arg0, arg1, arg2) {
|
||||
arg0[arg1] = arg2;
|
||||
};
|
||||
imports.wbg.__wbg_set_8fc6bf8a5b1071d1 = function(arg0, arg1, arg2) {
|
||||
const ret = arg0.set(arg1, arg2);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) {
|
||||
const ret = arg1.stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbindgen_init_externref_table = function() {
|
||||
const table = wasm.__wbindgen_export_3;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
;
|
||||
};
|
||||
imports.wbg.__wbindgen_number_new = function(arg0) {
|
||||
const ret = arg0;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_string_new = function(arg0, arg1) {
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_throw = function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
};
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
function __wbg_init_memory(imports, memory) {
|
||||
|
||||
}
|
||||
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasm = instance.exports;
|
||||
__wbg_init.__wbindgen_wasm_module = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
|
||||
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (typeof module !== 'undefined') {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
__wbg_init_memory(imports);
|
||||
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (typeof module_or_path !== 'undefined') {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module_or_path === 'undefined') {
|
||||
module_or_path = new URL('geo_wasm_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
__wbg_init_memory(imports);
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync };
|
||||
export default __wbg_init;
|
BIN
src/assets/wasm/geo/geo_wasm_bg.wasm
Normal file
BIN
src/assets/wasm/geo/geo_wasm_bg.wasm
Normal file
Binary file not shown.
338
src/assets/wasm/search/search_wasm.js
Normal file
338
src/assets/wasm/search/search_wasm.js
Normal file
@ -0,0 +1,338 @@
|
||||
let wasm;
|
||||
|
||||
function addToExternrefTable0(obj) {
|
||||
const idx = wasm.__externref_table_alloc();
|
||||
wasm.__wbindgen_export_2.set(idx, obj);
|
||||
return idx;
|
||||
}
|
||||
|
||||
function handleError(f, args) {
|
||||
try {
|
||||
return f.apply(this, args);
|
||||
} catch (e) {
|
||||
const idx = addToExternrefTable0(e);
|
||||
wasm.__wbindgen_exn_store(idx);
|
||||
}
|
||||
}
|
||||
|
||||
const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
|
||||
|
||||
if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); };
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
const cachedTextEncoder = (typeof TextEncoder !== 'undefined' ? new TextEncoder('utf-8') : { encode: () => { throw Error('TextEncoder not available') } } );
|
||||
|
||||
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
|
||||
? function (arg, view) {
|
||||
return cachedTextEncoder.encodeInto(arg, view);
|
||||
}
|
||||
: function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
});
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = encodeString(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function passArray8ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_export_2.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* WASM入口点 - 搜索文章
|
||||
* @param {Uint8Array} index_data
|
||||
* @param {string} request_json
|
||||
* @returns {string}
|
||||
*/
|
||||
export function search_articles(index_data, request_json) {
|
||||
let deferred4_0;
|
||||
let deferred4_1;
|
||||
try {
|
||||
const ptr0 = passArray8ToWasm0(index_data, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.search_articles(ptr0, len0, ptr1, len1);
|
||||
var ptr3 = ret[0];
|
||||
var len3 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr3 = 0; len3 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred4_0 = ptr3;
|
||||
deferred4_1 = len3;
|
||||
return getStringFromWasm0(ptr3, len3);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred4_0, deferred4_1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
|
||||
} catch (e) {
|
||||
if (module.headers.get('Content-Type') != 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function __wbg_get_imports() {
|
||||
const imports = {};
|
||||
imports.wbg = {};
|
||||
imports.wbg.__wbg_call_672a4d21634d4a24 = function() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.call(arg1);
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_error_7534b8e9a36f1ab4 = function(arg0, arg1) {
|
||||
let deferred0_0;
|
||||
let deferred0_1;
|
||||
try {
|
||||
deferred0_0 = arg0;
|
||||
deferred0_1 = arg1;
|
||||
console.error(getStringFromWasm0(arg0, arg1));
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
|
||||
}
|
||||
};
|
||||
imports.wbg.__wbg_instanceof_Window_def73ea0955fc569 = function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof Window;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new_8a6f238a6ece86ea = function() {
|
||||
const ret = new Error();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_newnoargs_105ed471475aaf50 = function(arg0, arg1) {
|
||||
const ret = new Function(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_now_d18023d54d4e5500 = function(arg0) {
|
||||
const ret = arg0.now();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_performance_c185c0cdc2766575 = function(arg0) {
|
||||
const ret = arg0.performance;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
};
|
||||
imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) {
|
||||
const ret = arg1.stack;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbg_static_accessor_GLOBAL_88a902d13a557d07 = function() {
|
||||
const ret = typeof global === 'undefined' ? null : global;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
};
|
||||
imports.wbg.__wbg_static_accessor_GLOBAL_THIS_56578be7e9f832b0 = function() {
|
||||
const ret = typeof globalThis === 'undefined' ? null : globalThis;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
};
|
||||
imports.wbg.__wbg_static_accessor_SELF_37c5d418e4bf5819 = function() {
|
||||
const ret = typeof self === 'undefined' ? null : self;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
};
|
||||
imports.wbg.__wbg_static_accessor_WINDOW_5de37043a91a9c40 = function() {
|
||||
const ret = typeof window === 'undefined' ? null : window;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
};
|
||||
imports.wbg.__wbindgen_init_externref_table = function() {
|
||||
const table = wasm.__wbindgen_export_2;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
;
|
||||
};
|
||||
imports.wbg.__wbindgen_is_undefined = function(arg0) {
|
||||
const ret = arg0 === undefined;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_string_new = function(arg0, arg1) {
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_throw = function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
};
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
function __wbg_init_memory(imports, memory) {
|
||||
|
||||
}
|
||||
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasm = instance.exports;
|
||||
__wbg_init.__wbindgen_wasm_module = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
|
||||
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (typeof module !== 'undefined') {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
__wbg_init_memory(imports);
|
||||
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (typeof module_or_path !== 'undefined') {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module_or_path === 'undefined') {
|
||||
module_or_path = new URL('search_wasm_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
__wbg_init_memory(imports);
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync };
|
||||
export default __wbg_init;
|
BIN
src/assets/wasm/search/search_wasm_bg.wasm
Normal file
BIN
src/assets/wasm/search/search_wasm_bg.wasm
Normal file
Binary file not shown.
1953
src/components/ArticleFilter.tsx
Normal file
1953
src/components/ArticleFilter.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -29,7 +29,7 @@ const breadcrumbs: Breadcrumb[] = pathSegments
|
||||
});
|
||||
---
|
||||
|
||||
<div class="flex items-center justify-between w-full flex-wrap sm:flex-nowrap">
|
||||
<div class="flex items-center justify-between w-full flex-wrap sm:flex-nowrap" transition:persist transition:name="breadcrumb">
|
||||
<div class="flex items-center text-sm overflow-hidden">
|
||||
<!-- 文章列表链接 - 根据当前页面类型决定链接 -->
|
||||
<a href={'/articles/'} class="text-secondary-600 dark:text-secondary-400 hover:text-primary-600 dark:hover:text-primary-400 flex items-center flex-shrink-0">
|
||||
|
@ -18,7 +18,7 @@ const currentYear = new Date().getFullYear();
|
||||
class="w-full py-6 px-4 bg-gray-50 dark:bg-dark-bg border-t border-gray-200 dark:border-gray-800 mt-auto"
|
||||
>
|
||||
<div
|
||||
class="max-w-5xl mx-auto flex flex-col items-center justify-center space-y-4"
|
||||
class="max-w-7xl mx-auto flex flex-col items-center justify-center space-y-4"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-center gap-4 text-sm text-gray-600 dark:text-gray-400"
|
||||
|
@ -301,7 +301,7 @@ const GitProjectCollection: React.FC<GitProjectCollectionProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`git-project-collection max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 ${className}`}>
|
||||
<div className={`git-project-collection w-full ${className}`}>
|
||||
<h2 className="text-2xl font-bold mb-6 text-primary-700 dark:text-primary-400">
|
||||
{displayTitle}
|
||||
{username && <span className="ml-2 text-secondary-500 dark:text-secondary-400">(@{username})</span>}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -17,6 +17,8 @@ interface Props {
|
||||
description?: string;
|
||||
date?: Date;
|
||||
tags?: string[];
|
||||
skipSrTitle?: boolean; // 控制是否跳过屏幕阅读器标题
|
||||
pageType?: "page" | "article" | "directory" ; // 更有语义的页面类型
|
||||
}
|
||||
|
||||
// 获取完整的 URL
|
||||
@ -28,6 +30,8 @@ const {
|
||||
description = SITE_DESCRIPTION,
|
||||
date,
|
||||
tags,
|
||||
skipSrTitle = false, // 默认显示屏幕阅读器标题
|
||||
pageType = "page", // 默认为普通页面
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
@ -56,23 +60,18 @@ const {
|
||||
content={Astro.generator}
|
||||
/>
|
||||
|
||||
|
||||
<!-- 使用 AstroSeo 组件替换原有的 SEO 标签 -->
|
||||
<AstroSeo
|
||||
title={title}
|
||||
description={description || `${SITE_NAME} - 个人博客`}
|
||||
canonical={canonicalURL.toString()}
|
||||
openGraph={{
|
||||
type: "article",
|
||||
type: pageType,
|
||||
url: canonicalURL.toString(),
|
||||
title: title,
|
||||
description: description || `${SITE_NAME} - 个人博客`,
|
||||
site_name: SITE_NAME,
|
||||
...(date && {
|
||||
article: {
|
||||
publishedTime: date.toISOString(),
|
||||
tags: tags || [],
|
||||
},
|
||||
}),
|
||||
}}
|
||||
twitter={{
|
||||
cardType: "summary_large_image",
|
||||
@ -80,14 +79,16 @@ const {
|
||||
handle: SITE_NAME,
|
||||
}}
|
||||
additionalMetaTags={[
|
||||
{
|
||||
// 仅对文章类型添加标准OpenGraph文章标记
|
||||
...(date && pageType === "article" ? [{
|
||||
property: "article:published_time",
|
||||
content: date ? date.toISOString() : "",
|
||||
},
|
||||
...(tags?.map((tag) => ({
|
||||
content: date.toISOString(),
|
||||
}] : []),
|
||||
// 文章标签使用标准格式
|
||||
...(pageType === "article" && tags ? tags.map((tag) => ({
|
||||
property: "article:tag",
|
||||
content: tag,
|
||||
})) || []),
|
||||
})) : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -214,7 +215,8 @@ const {
|
||||
class="m-0 w-full h-full bg-gray-50 dark:bg-dark-bg flex flex-col min-h-screen"
|
||||
>
|
||||
<Header />
|
||||
<main class="pt-16 flex-grow">
|
||||
<main class="pt-16 flex-grow max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 w-full">
|
||||
{!skipSrTitle && <h1 class="sr-only">{title}</h1>}
|
||||
<slot />
|
||||
</main>
|
||||
<Footer
|
||||
|
@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
interface MediaGridProps {
|
||||
type: "movie" | "book";
|
||||
title: string;
|
||||
doubanId: string;
|
||||
}
|
||||
|
||||
@ -12,7 +11,7 @@ interface MediaItem {
|
||||
link: string;
|
||||
}
|
||||
|
||||
const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
const MediaGrid: React.FC<MediaGridProps> = ({ type, doubanId }) => {
|
||||
const [items, setItems] = useState<MediaItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasMoreContent, setHasMoreContent] = useState(true);
|
||||
@ -375,9 +374,7 @@ const MediaGrid: React.FC<MediaGridProps> = ({ type, title, doubanId }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">{title}</h1>
|
||||
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={mediaListRef}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||
|
1097
src/components/Search.tsx
Normal file
1097
src/components/Search.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -145,13 +145,6 @@ const {
|
||||
? savedMode
|
||||
: TRANSITION_MODES.AUTO;
|
||||
}
|
||||
|
||||
// 保存主题过渡模式到本地存储
|
||||
function saveThemeTransitionMode(mode) {
|
||||
if (Object.values(TRANSITION_MODES).includes(mode)) {
|
||||
localStorage.setItem('theme-transition-mode', mode);
|
||||
}
|
||||
}
|
||||
|
||||
// 直接从按钮移除事件监听器
|
||||
function cleanupButtonListeners() {
|
||||
|
@ -2,8 +2,16 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
|
||||
import worldData from "@/assets/world.zh.json";
|
||||
import chinaData from "@/assets/china.json";
|
||||
|
||||
// WASM模块接口
|
||||
interface GeoWasmModule {
|
||||
GeoProcessor: new () => {
|
||||
process_geojson: (worldData: string, chinaData: string, visitedPlaces: string, scale: number) => void;
|
||||
get_boundary_lines: () => any[];
|
||||
find_nearest_country: (x: number, y: number, z: number, radius: number) => string | null;
|
||||
};
|
||||
default?: () => Promise<any>;
|
||||
}
|
||||
|
||||
interface WorldHeatmapProps {
|
||||
visitedPlaces: string[];
|
||||
@ -19,6 +27,23 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
? "dark"
|
||||
: "light",
|
||||
);
|
||||
|
||||
// 用于存储WASM模块和处理器实例
|
||||
const [wasmModule, setWasmModule] = useState<GeoWasmModule | null>(null);
|
||||
const [geoProcessor, setGeoProcessor] = useState<any>(null);
|
||||
const [wasmError, setWasmError] = useState<string | null>(null);
|
||||
// 添加状态标记WASM是否已准备好
|
||||
const [wasmReady, setWasmReady] = useState(false);
|
||||
|
||||
// 添加地图数据状态
|
||||
const [mapData, setMapData] = useState<{
|
||||
worldData: any | null;
|
||||
chinaData: any | null;
|
||||
}>({ worldData: null, chinaData: null });
|
||||
|
||||
// 添加地图加载状态
|
||||
const [mapLoading, setMapLoading] = useState(true);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
|
||||
const sceneRef = useRef<{
|
||||
scene: THREE.Scene;
|
||||
@ -37,8 +62,8 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
lastMouseX: number | null;
|
||||
lastMouseY: number | null;
|
||||
lastHoverTime: number | null;
|
||||
regionImportance?: Map<string, number>;
|
||||
importanceThreshold?: number;
|
||||
lineToCountryMap: Map<THREE.Line, string>;
|
||||
allLineObjects: THREE.Line[];
|
||||
} | null>(null);
|
||||
|
||||
// 监听主题变化
|
||||
@ -79,8 +104,86 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 动态加载地图数据
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const loadMapData = async () => {
|
||||
try {
|
||||
setMapLoading(true);
|
||||
setMapError(null);
|
||||
|
||||
// 并行加载两个地图数据
|
||||
const [worldDataModule, chinaDataModule] = await Promise.all([
|
||||
import("@/assets/map/world.zh.json"),
|
||||
import("@/assets/map/china.json")
|
||||
]);
|
||||
|
||||
setMapData({
|
||||
worldData: worldDataModule.default || worldDataModule,
|
||||
chinaData: chinaDataModule.default || chinaDataModule
|
||||
});
|
||||
|
||||
setMapLoading(false);
|
||||
} catch (err) {
|
||||
console.error("加载地图数据失败:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setMapError(`地图数据加载失败: ${errorMessage}`);
|
||||
setMapLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadMapData();
|
||||
}, []);
|
||||
|
||||
// 加载WASM模块
|
||||
useEffect(() => {
|
||||
const loadWasmModule = async () => {
|
||||
try {
|
||||
// 改为使用JS胶水文件方式加载WASM
|
||||
const wasm = await import(
|
||||
"@/assets/wasm/geo/geo_wasm.js"
|
||||
);
|
||||
if (typeof wasm.default === "function") {
|
||||
await wasm.default();
|
||||
}
|
||||
setWasmModule(wasm as unknown as GeoWasmModule);
|
||||
setWasmError(null);
|
||||
} catch (err) {
|
||||
console.error("加载WASM模块失败:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setWasmError(`WASM模块初始化失败: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
loadWasmModule();
|
||||
}, []);
|
||||
|
||||
// 初始化WASM数据
|
||||
useEffect(() => {
|
||||
if (!wasmModule || !mapData.worldData || !mapData.chinaData) return;
|
||||
|
||||
try {
|
||||
// 使用JS胶水层方式初始化WASM
|
||||
const geoProcessorInstance = new wasmModule.GeoProcessor();
|
||||
geoProcessorInstance.process_geojson(
|
||||
JSON.stringify(mapData.worldData),
|
||||
JSON.stringify(mapData.chinaData),
|
||||
JSON.stringify(visitedPlaces),
|
||||
2.01
|
||||
);
|
||||
|
||||
setGeoProcessor(geoProcessorInstance);
|
||||
setWasmReady(true);
|
||||
} catch (error) {
|
||||
console.error("WASM数据处理失败:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
setWasmError(`WASM数据处理失败: ${errorMessage}`);
|
||||
}
|
||||
}, [wasmModule, visitedPlaces, mapData.worldData, mapData.chinaData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !wasmModule || !wasmReady || !geoProcessor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理之前的场景
|
||||
if (sceneRef.current) {
|
||||
@ -92,7 +195,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
sceneRef.current.scene.clear();
|
||||
containerRef.current.innerHTML = "";
|
||||
}
|
||||
|
||||
|
||||
// 检查当前是否为暗色模式
|
||||
const isDarkMode =
|
||||
document.documentElement.classList.contains("dark") ||
|
||||
@ -101,13 +204,13 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
// 根据当前模式设置颜色
|
||||
const getColors = () => {
|
||||
return {
|
||||
earthBase: isDarkMode ? "#111827" : "#f3f4f6", // 深色模式更暗,浅色模式更亮
|
||||
earthBase: isDarkMode ? "#111827" : "#2a4d69", // 深色模式保持深色,浅色模式改为更柔和的蓝色
|
||||
visited: isDarkMode ? "#065f46" : "#34d399", // 访问过的颜色更鲜明
|
||||
border: isDarkMode ? "#6b7280" : "#d1d5db", // 边界颜色更柔和
|
||||
visitedBorder: isDarkMode ? "#10b981" : "#059669", // 访问过的边界颜色更鲜明
|
||||
border: isDarkMode ? "#6b7280" : "#e0e0e0", // 边界颜色调整为更亮的浅灰色
|
||||
visitedBorder: isDarkMode ? "#10b981" : "#0d9488", // 访问过的边界颜色
|
||||
chinaBorder: isDarkMode ? "#f87171" : "#ef4444", // 中国边界使用红色
|
||||
text: isDarkMode ? "#f9fafb" : "#1f2937", // 文本颜色对比更强
|
||||
highlight: isDarkMode ? "#fbbf24" : "#d97706", // 高亮颜色更适合当前主题
|
||||
highlight: isDarkMode ? "#fcd34d" : "#60a5fa", // 高亮颜色改为浅蓝色,更配合背景
|
||||
};
|
||||
};
|
||||
|
||||
@ -117,18 +220,6 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = null;
|
||||
|
||||
// 添加一个动态计算小区域的机制
|
||||
const regionSizeMetrics = new Map<
|
||||
string,
|
||||
{
|
||||
boundingBoxSize?: number;
|
||||
pointCount?: number;
|
||||
importance?: number;
|
||||
isSmallRegion?: boolean;
|
||||
polygonArea?: number;
|
||||
}
|
||||
>();
|
||||
|
||||
// 创建材质的辅助函数
|
||||
const createMaterial = (
|
||||
color: string,
|
||||
@ -148,7 +239,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
const earthMaterial = createMaterial(
|
||||
colors.earthBase,
|
||||
THREE.FrontSide,
|
||||
isDarkMode ? 0.9 : 0.8,
|
||||
isDarkMode ? 0.9 : 0.9, // 调整明亮模式下的不透明度
|
||||
);
|
||||
const earth = new THREE.Mesh(earthGeometry, earthMaterial);
|
||||
earth.renderOrder = 1;
|
||||
@ -157,13 +248,13 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
// 添加光源
|
||||
const ambientLight = new THREE.AmbientLight(
|
||||
0xffffff,
|
||||
isDarkMode ? 0.7 : 0.8,
|
||||
isDarkMode ? 0.7 : 0.85, // 微调明亮模式下的光照强度
|
||||
);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(
|
||||
isDarkMode ? 0xeeeeff : 0xffffff,
|
||||
isDarkMode ? 0.6 : 0.5,
|
||||
isDarkMode ? 0xeeeeff : 0xffffff, // 恢复明亮模式下的纯白光源
|
||||
isDarkMode ? 0.6 : 0.65, // 微调明亮模式下的定向光强度
|
||||
);
|
||||
directionalLight.position.set(5, 3, 5);
|
||||
scene.add(directionalLight);
|
||||
@ -225,277 +316,81 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 创建国家边界
|
||||
const countries = new Map<string, THREE.Object3D>();
|
||||
const countryGroup = new THREE.Group();
|
||||
earth.add(countryGroup);
|
||||
|
||||
// 保存所有线条对象的引用,用于快速检测
|
||||
const allLineObjects: THREE.Line[] = [];
|
||||
const lineToCountryMap = new Map<THREE.Line, string>();
|
||||
|
||||
// 保存所有国家和省份的边界盒,用于优化检测
|
||||
const countryBoundingBoxes = new Map<string, THREE.Box3>();
|
||||
|
||||
// 创建一个辅助函数,用于将经纬度转换为三维坐标
|
||||
const latLongToVector3 = (
|
||||
lat: number,
|
||||
lon: number,
|
||||
radius: number,
|
||||
): THREE.Vector3 => {
|
||||
// 调整经度范围,确保它在[-180, 180]之间
|
||||
while (lon > 180) lon -= 360;
|
||||
while (lon < -180) lon += 360;
|
||||
|
||||
const phi = ((90 - lat) * Math.PI) / 180;
|
||||
const theta = ((lon + 180) * Math.PI) / 180;
|
||||
|
||||
const x = -radius * Math.sin(phi) * Math.cos(theta);
|
||||
const y = radius * Math.cos(phi);
|
||||
const z = radius * Math.sin(phi) * Math.sin(theta);
|
||||
|
||||
return new THREE.Vector3(x, y, z);
|
||||
};
|
||||
|
||||
// 省份边界和中心点数据结构
|
||||
const provinceCenters = new Map<string, THREE.Vector3>();
|
||||
|
||||
// 创建一个通用函数,用于处理地理特性(国家或省份)
|
||||
const processGeoFeature = (
|
||||
feature: any,
|
||||
parent: THREE.Group,
|
||||
options: {
|
||||
regionType: "country" | "province";
|
||||
parentName?: string;
|
||||
scale?: number;
|
||||
borderColor?: string;
|
||||
visitedBorderColor?: string;
|
||||
},
|
||||
) => {
|
||||
const {
|
||||
regionType,
|
||||
parentName,
|
||||
scale = 2.01,
|
||||
borderColor,
|
||||
visitedBorderColor,
|
||||
} = options;
|
||||
|
||||
const regionName =
|
||||
regionType === "province" && parentName
|
||||
? `${parentName}-${feature.properties.name}`
|
||||
: feature.properties.name;
|
||||
|
||||
const isRegionVisited = visitedPlaces.includes(regionName);
|
||||
|
||||
// 为每个地区创建一个组
|
||||
const regionObject = new THREE.Group();
|
||||
regionObject.userData = { name: regionName, isVisited: isRegionVisited };
|
||||
|
||||
// 计算地区中心点
|
||||
let centerLon = 0;
|
||||
let centerLat = 0;
|
||||
let pointCount = 0;
|
||||
|
||||
// 创建边界盒用于碰撞检测
|
||||
const boundingBox = new THREE.Box3();
|
||||
|
||||
// 首先检查GeoJSON特性中是否有预定义的中心点
|
||||
let hasPreDefinedCenter = false;
|
||||
let centerVector;
|
||||
|
||||
if (
|
||||
feature.properties.cp &&
|
||||
Array.isArray(feature.properties.cp) &&
|
||||
feature.properties.cp.length === 2
|
||||
) {
|
||||
const [cpLon, cpLat] = feature.properties.cp;
|
||||
hasPreDefinedCenter = true;
|
||||
centerVector = latLongToVector3(cpLat, cpLon, scale + 0.005);
|
||||
centerLon = cpLon;
|
||||
centerLat = cpLat;
|
||||
|
||||
// 保存预定义中心点
|
||||
if (regionType === "province") {
|
||||
provinceCenters.set(regionName, centerVector);
|
||||
}
|
||||
}
|
||||
|
||||
// 存储区域边界
|
||||
const boundaries: THREE.Vector3[][] = [];
|
||||
|
||||
// 处理多边形坐标
|
||||
const processPolygon = (polygonCoords: any) => {
|
||||
const points: THREE.Vector3[] = [];
|
||||
|
||||
// 收集多边形的点
|
||||
polygonCoords.forEach((point: number[]) => {
|
||||
const lon = point[0];
|
||||
const lat = point[1];
|
||||
centerLon += lon;
|
||||
centerLat += lat;
|
||||
pointCount++;
|
||||
|
||||
// 使用辅助函数将经纬度转换为3D坐标
|
||||
const vertex = latLongToVector3(lat, lon, scale);
|
||||
points.push(vertex);
|
||||
|
||||
// 扩展边界盒以包含此点
|
||||
boundingBox.expandByPoint(vertex);
|
||||
});
|
||||
|
||||
// 保存边界多边形
|
||||
if (points.length > 2) {
|
||||
boundaries.push(points);
|
||||
}
|
||||
|
||||
// 收集区域大小指标
|
||||
if (!regionSizeMetrics.has(regionName)) {
|
||||
regionSizeMetrics.set(regionName, {});
|
||||
}
|
||||
|
||||
const metrics = regionSizeMetrics.get(regionName)!;
|
||||
if (points.length > 2) {
|
||||
// 计算边界框大小
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
minZ = Infinity;
|
||||
let maxX = -Infinity,
|
||||
maxY = -Infinity,
|
||||
maxZ = -Infinity;
|
||||
|
||||
points.forEach((point) => {
|
||||
minX = Math.min(minX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
minZ = Math.min(minZ, point.z);
|
||||
maxX = Math.max(maxX, point.x);
|
||||
maxY = Math.max(maxY, point.y);
|
||||
maxZ = Math.max(maxZ, point.z);
|
||||
});
|
||||
|
||||
const sizeX = maxX - minX;
|
||||
const sizeY = maxY - minY;
|
||||
const sizeZ = maxZ - minZ;
|
||||
const boxSize = Math.sqrt(
|
||||
sizeX * sizeX + sizeY * sizeY + sizeZ * sizeZ,
|
||||
);
|
||||
|
||||
// 更新或初始化指标
|
||||
metrics.boundingBoxSize = metrics.boundingBoxSize
|
||||
? Math.max(metrics.boundingBoxSize, boxSize)
|
||||
: boxSize;
|
||||
metrics.pointCount = (metrics.pointCount || 0) + points.length;
|
||||
}
|
||||
|
||||
|
||||
// 创建国家边界组
|
||||
const countryGroup = new THREE.Group();
|
||||
earth.add(countryGroup);
|
||||
|
||||
// 创建国家边界
|
||||
const countries = new Map<string, THREE.Object3D>();
|
||||
|
||||
// 从WASM获取边界线数据
|
||||
const boundaryLines = geoProcessor.get_boundary_lines();
|
||||
|
||||
// 处理边界线数据
|
||||
if (boundaryLines) {
|
||||
// 遍历所有边界线
|
||||
for (const boundaryLine of boundaryLines) {
|
||||
const { points, region_name, is_visited } = boundaryLine;
|
||||
|
||||
// 创建区域组
|
||||
const regionObject = new THREE.Group();
|
||||
regionObject.userData = { name: region_name, isVisited: is_visited };
|
||||
|
||||
// 转换点数组为THREE.Vector3数组
|
||||
const threePoints = points.map((p: { x: number; y: number; z: number }) => new THREE.Vector3(p.x, p.y, p.z));
|
||||
|
||||
// 创建边界线
|
||||
if (points.length > 1) {
|
||||
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
if (threePoints.length > 1) {
|
||||
const lineGeometry = new THREE.BufferGeometry().setFromPoints(threePoints);
|
||||
|
||||
// 确定线条颜色
|
||||
const isChina = region_name === "中国" || region_name.startsWith("中国-");
|
||||
let borderColor;
|
||||
|
||||
if (is_visited) {
|
||||
// 已访问的地区,包括中国城市,都使用绿色边界
|
||||
borderColor = colors.visitedBorder;
|
||||
} else if (isChina) {
|
||||
// 未访问的中国和中国区域使用红色边界
|
||||
borderColor = colors.chinaBorder;
|
||||
} else {
|
||||
// 其他未访问区域使用默认边界颜色
|
||||
borderColor = colors.border;
|
||||
}
|
||||
|
||||
const lineMaterial = new THREE.LineBasicMaterial({
|
||||
color: isRegionVisited
|
||||
? visitedBorderColor || colors.visitedBorder
|
||||
: borderColor || colors.border,
|
||||
linewidth: isRegionVisited ? 1.5 : 1,
|
||||
color: borderColor,
|
||||
linewidth: is_visited ? 1.8 : 1.2, // 微调线条宽度,保持已访问区域更明显
|
||||
transparent: true,
|
||||
opacity: isRegionVisited ? 0.9 : 0.7,
|
||||
opacity: is_visited ? 0.95 : 0.85, // 调整不透明度,使边界明显但不突兀
|
||||
});
|
||||
|
||||
const line = new THREE.Line(lineGeometry, lineMaterial);
|
||||
line.userData = {
|
||||
name: regionName,
|
||||
isVisited: isRegionVisited,
|
||||
originalColor: isRegionVisited
|
||||
? visitedBorderColor || colors.visitedBorder
|
||||
: borderColor || colors.border,
|
||||
highlightColor: colors.highlight, // 使用主题颜色中定义的高亮颜色
|
||||
name: region_name,
|
||||
isVisited: is_visited,
|
||||
originalColor: borderColor,
|
||||
highlightColor: colors.highlight,
|
||||
};
|
||||
|
||||
// 设置渲染顺序
|
||||
line.renderOrder = isRegionVisited ? 3 : 2;
|
||||
line.renderOrder = is_visited ? 3 : 2;
|
||||
regionObject.add(line);
|
||||
|
||||
// 保存线条对象引用和对应的国家/地区名称
|
||||
|
||||
// 保存线条对象引用和对应的区域名称
|
||||
allLineObjects.push(line);
|
||||
lineToCountryMap.set(line, regionName);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理不同类型的几何体
|
||||
if (
|
||||
feature.geometry &&
|
||||
(feature.geometry.type === "Polygon" ||
|
||||
feature.geometry.type === "MultiPolygon")
|
||||
) {
|
||||
if (feature.geometry.type === "Polygon") {
|
||||
feature.geometry.coordinates.forEach((ring: any) => {
|
||||
processPolygon(ring);
|
||||
});
|
||||
} else if (feature.geometry.type === "MultiPolygon") {
|
||||
feature.geometry.coordinates.forEach((polygon: any) => {
|
||||
polygon.forEach((ring: any) => {
|
||||
processPolygon(ring);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (pointCount > 0 && !hasPreDefinedCenter) {
|
||||
// 计算平均中心点
|
||||
centerLon /= pointCount;
|
||||
centerLat /= pointCount;
|
||||
|
||||
// 将中心点经纬度转换为3D坐标
|
||||
centerVector = latLongToVector3(centerLat, centerLon, scale + 0.005);
|
||||
|
||||
// 保存计算的中心点
|
||||
if (regionType === "province") {
|
||||
provinceCenters.set(regionName, centerVector);
|
||||
}
|
||||
}
|
||||
|
||||
if (pointCount > 0) {
|
||||
// 保存地区的边界盒
|
||||
countryBoundingBoxes.set(regionName, boundingBox);
|
||||
|
||||
// 添加地区对象到父组
|
||||
parent.add(regionObject);
|
||||
countries.set(regionName, regionObject);
|
||||
lineToCountryMap.set(line, region_name);
|
||||
}
|
||||
|
||||
// 添加区域对象到国家组
|
||||
countryGroup.add(regionObject);
|
||||
countries.set(region_name, regionObject);
|
||||
}
|
||||
|
||||
return regionObject;
|
||||
};
|
||||
|
||||
// 处理世界GeoJSON数据
|
||||
worldData.features.forEach((feature: any) => {
|
||||
const countryName = feature.properties.name;
|
||||
|
||||
// 跳过中国,因为我们将使用更详细的中国地图数据
|
||||
if (countryName === "中国") return;
|
||||
|
||||
processGeoFeature(feature, countryGroup, {
|
||||
regionType: "country",
|
||||
scale: 2.01,
|
||||
});
|
||||
});
|
||||
|
||||
// 处理中国的省份
|
||||
const chinaObject = new THREE.Group();
|
||||
chinaObject.userData = {
|
||||
name: "中国",
|
||||
isVisited: visitedPlaces.includes("中国"),
|
||||
};
|
||||
|
||||
chinaData.features.forEach((feature: any) => {
|
||||
processGeoFeature(feature, chinaObject, {
|
||||
regionType: "province",
|
||||
parentName: "中国",
|
||||
scale: 2.015,
|
||||
borderColor: colors.chinaBorder,
|
||||
visitedBorderColor: colors.visitedBorder,
|
||||
});
|
||||
});
|
||||
|
||||
// 添加中国对象到国家组
|
||||
countryGroup.add(chinaObject);
|
||||
countries.set("中国", chinaObject);
|
||||
}
|
||||
|
||||
// 将视图旋转到中国位置
|
||||
const positionCameraToFaceChina = () => {
|
||||
@ -518,15 +413,8 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
camera.lookAt(0, 0, 0);
|
||||
controls.update();
|
||||
|
||||
// 禁用自动旋转一段时间
|
||||
controls.autoRotate = false;
|
||||
|
||||
// 6秒后恢复旋转
|
||||
setTimeout(() => {
|
||||
if (sceneRef.current) {
|
||||
sceneRef.current.controls.autoRotate = true;
|
||||
}
|
||||
}, 6000);
|
||||
// 确保自动旋转始终开启
|
||||
controls.autoRotate = true;
|
||||
|
||||
// 渲染
|
||||
renderer.render(scene, camera);
|
||||
@ -570,115 +458,14 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
};
|
||||
};
|
||||
|
||||
// 根据球面上的点找到最近的国家或地区
|
||||
const findNearestCountry = (point: THREE.Vector3): string | null => {
|
||||
let closestCountry = null;
|
||||
let minDistance = Infinity;
|
||||
let smallRegionDistance = Infinity;
|
||||
let smallRegionCountry = null;
|
||||
|
||||
// 遍历所有国家/地区的边界盒
|
||||
for (const [countryName, box] of countryBoundingBoxes.entries()) {
|
||||
// 计算点到边界盒的距离
|
||||
const distance = box.distanceToPoint(point);
|
||||
|
||||
// 估算边界盒大小
|
||||
const boxSize = box.getSize(new THREE.Vector3()).length();
|
||||
|
||||
// 如果点在边界盒内或距离非常近,直接选择该区域
|
||||
if (distance < 0.001) {
|
||||
return countryName;
|
||||
}
|
||||
|
||||
// 同时跟踪绝对最近的区域
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestCountry = countryName;
|
||||
}
|
||||
|
||||
// 对于小区域,使用加权距离
|
||||
// 小区域的阈值(较小的边界盒尺寸)
|
||||
const SMALL_REGION_THRESHOLD = 0.5;
|
||||
if (boxSize < SMALL_REGION_THRESHOLD) {
|
||||
// 针对小区域的加权距离(降低小区域的选中难度)
|
||||
const weightedDistance = distance * (0.5 + boxSize / 2);
|
||||
if (weightedDistance < smallRegionDistance) {
|
||||
smallRegionDistance = weightedDistance;
|
||||
smallRegionCountry = countryName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 小区域优化逻辑
|
||||
if (smallRegionCountry && smallRegionDistance < minDistance * 2) {
|
||||
return smallRegionCountry;
|
||||
}
|
||||
|
||||
// 处理中国的特殊情况 - 如果点击非常接近省份边界
|
||||
if (closestCountry === "中国") {
|
||||
// 查找最近的中国省份
|
||||
let closestProvince = null;
|
||||
let minProvinceDistance = Infinity;
|
||||
|
||||
// 查找最近的中国省份
|
||||
for (const [countryName, box] of countryBoundingBoxes.entries()) {
|
||||
if (countryName.startsWith("中国-")) {
|
||||
const distance = box.distanceToPoint(point);
|
||||
if (distance < minProvinceDistance) {
|
||||
minProvinceDistance = distance;
|
||||
closestProvince = countryName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closestProvince && minProvinceDistance < minDistance * 1.5) {
|
||||
return closestProvince;
|
||||
}
|
||||
}
|
||||
|
||||
return closestCountry;
|
||||
};
|
||||
|
||||
// 解决射线检测和球面相交的问题
|
||||
const getPointOnSphere = (
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
camera: THREE.Camera,
|
||||
radius: number,
|
||||
): THREE.Vector3 | null => {
|
||||
// 计算鼠标在画布中的归一化坐标
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
const x = ((mouseX - rect.left) / rect.width) * 2 - 1;
|
||||
const y = -((mouseY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
// 创建射线
|
||||
const ray = new THREE.Raycaster();
|
||||
ray.setFromCamera(new THREE.Vector2(x, y), camera);
|
||||
|
||||
// 检测射线与实际地球模型的相交
|
||||
const earthIntersects = ray.intersectObject(earth, false);
|
||||
if (earthIntersects.length > 0) {
|
||||
return earthIntersects[0].point;
|
||||
}
|
||||
|
||||
// 如果没有直接相交,使用球体辅助检测
|
||||
const sphereGeom = new THREE.SphereGeometry(radius, 32, 32);
|
||||
const sphereMesh = new THREE.Mesh(sphereGeom);
|
||||
|
||||
const intersects = ray.intersectObject(sphereMesh);
|
||||
if (intersects.length > 0) {
|
||||
return intersects[0].point;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 简化的鼠标移动事件处理函数
|
||||
const onMouseMove = throttle((event: MouseEvent) => {
|
||||
if (!containerRef.current || !sceneRef.current) return;
|
||||
if (!containerRef.current || !sceneRef.current || !geoProcessor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取鼠标在球面上的点
|
||||
const spherePoint = getPointOnSphere(
|
||||
const result = getPointOnSphere(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
@ -692,40 +479,28 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 如果找到点,寻找最近的国家/地区
|
||||
if (spherePoint) {
|
||||
const countryName = findNearestCountry(spherePoint);
|
||||
|
||||
if (countryName) {
|
||||
// 高亮显示该国家/地区的线条
|
||||
allLineObjects.forEach((line) => {
|
||||
if (
|
||||
lineToCountryMap.get(line) === countryName &&
|
||||
line.material instanceof THREE.LineBasicMaterial
|
||||
) {
|
||||
line.material.color.set(line.userData.highlightColor);
|
||||
}
|
||||
});
|
||||
|
||||
// 更新悬停国家
|
||||
if (countryName !== hoveredCountry) {
|
||||
setHoveredCountry(countryName);
|
||||
// 如果找到点和对应的国家/地区
|
||||
if (result && result.countryName) {
|
||||
// 高亮显示该国家/地区的线条
|
||||
allLineObjects.forEach((line) => {
|
||||
if (
|
||||
lineToCountryMap.get(line) === result.countryName &&
|
||||
line.material instanceof THREE.LineBasicMaterial
|
||||
) {
|
||||
line.material.color.set(line.userData.highlightColor);
|
||||
}
|
||||
});
|
||||
|
||||
// 禁用自动旋转
|
||||
controls.autoRotate = false;
|
||||
} else {
|
||||
// 如果没有找到国家/地区,清除悬停状态
|
||||
if (hoveredCountry) {
|
||||
setHoveredCountry(null);
|
||||
controls.autoRotate = true;
|
||||
}
|
||||
// 更新悬停国家
|
||||
if (result.countryName !== hoveredCountry) {
|
||||
setHoveredCountry(result.countryName);
|
||||
}
|
||||
|
||||
// 不禁用自动旋转,保持地球旋转
|
||||
} else {
|
||||
// 如果没有找到球面点,清除悬停状态
|
||||
// 如果没有找到国家/地区,清除悬停状态
|
||||
if (hoveredCountry) {
|
||||
setHoveredCountry(null);
|
||||
controls.autoRotate = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -750,53 +525,49 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
sceneRef.current.lastClickedCountry = null;
|
||||
sceneRef.current.lastHoverTime = null;
|
||||
}
|
||||
// 确保自动旋转始终开启
|
||||
controls.autoRotate = true;
|
||||
};
|
||||
|
||||
// 简化的鼠标点击事件处理函数
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (!containerRef.current || !sceneRef.current) return;
|
||||
if (!containerRef.current || !sceneRef.current || !geoProcessor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取鼠标在球面上的点
|
||||
const spherePoint = getPointOnSphere(
|
||||
const result = getPointOnSphere(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
2.01,
|
||||
);
|
||||
|
||||
// 如果找到点,寻找最近的国家/地区
|
||||
if (spherePoint) {
|
||||
const countryName = findNearestCountry(spherePoint);
|
||||
// 如果找到点和对应的国家/地区
|
||||
if (result && result.countryName) {
|
||||
// 重置所有线条颜色
|
||||
allLineObjects.forEach((line) => {
|
||||
if (line.material instanceof THREE.LineBasicMaterial) {
|
||||
line.material.color.set(line.userData.originalColor);
|
||||
}
|
||||
});
|
||||
|
||||
if (countryName) {
|
||||
// 重置所有线条颜色
|
||||
allLineObjects.forEach((line) => {
|
||||
if (line.material instanceof THREE.LineBasicMaterial) {
|
||||
line.material.color.set(line.userData.originalColor);
|
||||
}
|
||||
});
|
||||
// 高亮显示该国家/地区的线条
|
||||
allLineObjects.forEach((line) => {
|
||||
if (
|
||||
lineToCountryMap.get(line) === result.countryName &&
|
||||
line.material instanceof THREE.LineBasicMaterial
|
||||
) {
|
||||
line.material.color.set(line.userData.highlightColor);
|
||||
}
|
||||
});
|
||||
|
||||
// 高亮显示该国家/地区的线条
|
||||
allLineObjects.forEach((line) => {
|
||||
if (
|
||||
lineToCountryMap.get(line) === countryName &&
|
||||
line.material instanceof THREE.LineBasicMaterial
|
||||
) {
|
||||
line.material.color.set(line.userData.highlightColor);
|
||||
}
|
||||
});
|
||||
|
||||
// 更新选中国家
|
||||
setHoveredCountry(countryName);
|
||||
sceneRef.current.lastClickedCountry = countryName;
|
||||
controls.autoRotate = false;
|
||||
} else {
|
||||
// 如果没有找到国家/地区,清除选择
|
||||
clearSelection();
|
||||
}
|
||||
// 更新选中国家
|
||||
setHoveredCountry(result.countryName);
|
||||
sceneRef.current.lastClickedCountry = result.countryName;
|
||||
// 不禁用自动旋转,保持地球始终旋转
|
||||
} else {
|
||||
// 如果没有找到球面点,清除选择
|
||||
// 如果没有找到国家/地区,清除选择
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
@ -814,9 +585,9 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
};
|
||||
|
||||
// 添加事件监听器
|
||||
containerRef.current.addEventListener("mousemove", onMouseMove);
|
||||
containerRef.current.addEventListener("click", onClick);
|
||||
containerRef.current.addEventListener("dblclick", onDoubleClick);
|
||||
containerRef.current.addEventListener("mousemove", onMouseMove, { passive: true });
|
||||
containerRef.current.addEventListener("click", onClick, { passive: false });
|
||||
containerRef.current.addEventListener("dblclick", onDoubleClick, { passive: false });
|
||||
|
||||
// 简化的动画循环函数
|
||||
const animate = () => {
|
||||
@ -851,8 +622,59 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
lastMouseX: null,
|
||||
lastMouseY: null,
|
||||
lastHoverTime: null,
|
||||
regionImportance: undefined,
|
||||
importanceThreshold: undefined,
|
||||
lineToCountryMap,
|
||||
allLineObjects,
|
||||
};
|
||||
|
||||
// 开始动画
|
||||
sceneRef.current.animationId = requestAnimationFrame(animate);
|
||||
|
||||
// 获取球面上的点对应的国家/地区
|
||||
const getPointOnSphere = (
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
camera: THREE.Camera,
|
||||
radius: number,
|
||||
): { point: THREE.Vector3, countryName: string | null } | null => {
|
||||
// 计算鼠标在画布中的归一化坐标
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
const x = ((mouseX - rect.left) / rect.width) * 2 - 1;
|
||||
const y = -((mouseY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
// 创建射线
|
||||
const ray = new THREE.Raycaster();
|
||||
ray.setFromCamera(new THREE.Vector2(x, y), camera);
|
||||
|
||||
// 检测射线与实际地球模型的相交
|
||||
const earthIntersects = ray.intersectObject(earth, false);
|
||||
if (earthIntersects.length > 0) {
|
||||
const point = earthIntersects[0].point;
|
||||
|
||||
// 使用WASM查找最近的国家/地区
|
||||
const countryName = geoProcessor.find_nearest_country(
|
||||
point.x, point.y, point.z, radius
|
||||
);
|
||||
|
||||
return { point, countryName };
|
||||
}
|
||||
|
||||
// 如果没有直接相交,使用球体辅助检测
|
||||
const sphereGeom = new THREE.SphereGeometry(radius, 32, 32);
|
||||
const sphereMesh = new THREE.Mesh(sphereGeom);
|
||||
|
||||
const intersects = ray.intersectObject(sphereMesh);
|
||||
if (intersects.length > 0) {
|
||||
const point = intersects[0].point;
|
||||
|
||||
// 使用WASM查找最近的国家/地区
|
||||
const countryName = geoProcessor.find_nearest_country(
|
||||
point.x, point.y, point.z, radius
|
||||
);
|
||||
|
||||
return { point, countryName };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 处理窗口大小变化
|
||||
@ -873,10 +695,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
labelRenderer.render(sceneRef.current.scene, camera);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// 开始动画
|
||||
sceneRef.current.animationId = requestAnimationFrame(animate);
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
@ -913,7 +732,7 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
// 移除窗口事件监听器
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [visitedPlaces, theme]); // 依赖于visitedPlaces和theme变化
|
||||
}, [visitedPlaces, theme, wasmReady, geoProcessor]); // 添加geoProcessor依赖
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@ -921,6 +740,31 @@ const WorldHeatmap: React.FC<WorldHeatmapProps> = ({ visitedPlaces }) => {
|
||||
ref={containerRef}
|
||||
className="w-full h-[400px] sm:h-[450px] md:h-[500px] lg:h-[600px] xl:h-[700px]"
|
||||
/>
|
||||
|
||||
{(wasmError || mapError) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80 z-20">
|
||||
<div className="bg-red-50 dark:bg-red-900 p-4 rounded-lg shadow-lg max-w-md">
|
||||
<h3 className="text-red-700 dark:text-red-300 font-bold text-lg mb-2">地图加载错误</h3>
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{wasmError || mapError}</p>
|
||||
<button
|
||||
className="mt-3 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapLoading && !mapError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80 z-20">
|
||||
<div className="text-center">
|
||||
<div className="inline-block w-12 h-12 border-4 border-gray-300 dark:border-gray-600 border-t-blue-500 dark:border-t-blue-400 rounded-full animate-spin"></div>
|
||||
<p className="mt-3 text-gray-700 dark:text-gray-300">加载地图数据中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hoveredCountry && (
|
||||
<div className="absolute bottom-5 left-0 right-0 text-center z-10">
|
||||
<div className="inline-block bg-white/95 dark:bg-gray-800/95 px-6 py-3 rounded-xl shadow-lg backdrop-blur-sm border border-gray-200 dark:border-gray-700 hover:scale-105">
|
||||
|
@ -2,6 +2,7 @@ export const SITE_URL = 'https://blog.lsy22.com';
|
||||
export const SITE_NAME = "echoes";
|
||||
export const SITE_DESCRIPTION = "记录生活,分享所思";
|
||||
|
||||
// 原始导航链接(保留用于兼容)
|
||||
export const NAV_LINKS = [
|
||||
{ href: '/', text: '首页' },
|
||||
{ href: '/filtered', text: '筛选' },
|
||||
@ -12,6 +13,39 @@ export const NAV_LINKS = [
|
||||
{ href: '/other', text: '其他' },
|
||||
];
|
||||
|
||||
// 新的导航结构 - 支持分层导航
|
||||
export const NAV_STRUCTURE = [
|
||||
{
|
||||
id: 'home',
|
||||
text: '首页',
|
||||
href: '/'
|
||||
},
|
||||
{
|
||||
id: 'douban',
|
||||
text: '豆瓣',
|
||||
items: [
|
||||
{ id: 'movies', text: '观影', href: '/movies' },
|
||||
{ id: 'books', text: '读书', href: '/books' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'articles',
|
||||
text: '文章',
|
||||
items: [
|
||||
{ id: 'filter', text: '筛选', href: '/filtered' },
|
||||
{ id: 'path', text: '文章', href: '/articles' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'others',
|
||||
text: '其他',
|
||||
items: [
|
||||
{ id: 'other', text: '其他', href: '/other' },
|
||||
{ id: 'projects', text: '项目', href: '/projects' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const ICP = '渝ICP备2022009272号';
|
||||
export const PSB_ICP = '渝公网安备50011902000520号';
|
||||
export const PSB_ICP_URL = 'http://www.beian.gov.cn/portal/registerSystemInfo';
|
||||
|
@ -1,179 +0,0 @@
|
||||
---
|
||||
title: "用rust实现第一个wasm"
|
||||
date: 2024-10-19T15:09:25+08:00
|
||||
tags: ["rust", "webassembly"]
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```text
|
||||
wasm-project/
|
||||
│
|
||||
├── Cargo.toml # Rust包的配置文件
|
||||
├── package.json # npm项目配置文件
|
||||
├── webpack.config.js # Webpack配置文件
|
||||
├── src/ # 源代码目录
|
||||
│ ├── lib.rs # Rust代码,导出到WebAssembly
|
||||
│ ├── index.js # JavaScript代码,调用WebAssembly模块
|
||||
│ └── index.html # 简单的HTML页面,用于测试WebAssembly
|
||||
├── pkg/ # wasm-pack生成的WebAssembly相关文件
|
||||
│ ├── package.json # WebAssembly模块的npm配置
|
||||
│ ├── wasm.d.ts # TypeScript类型定义文件
|
||||
│ ├── wasm.js # WebAssembly模块的JavaScript接口
|
||||
│ ├── wasm_bg.js # WebAssembly模块的绑定代码
|
||||
│ ├── wasm_bg.wasm # 编译生成的WebAssembly二进制文件
|
||||
│ └── wasm_bg.wasm.d.ts # WebAssembly二进制文件的TypeScript声明
|
||||
└── dist/ # Webpack打包生成的文件
|
||||
├── main.js # 打包后的JavaScript文件
|
||||
├── pkg_wasm_js.js # WebAssembly模块的打包文件
|
||||
└── 22e4d62519dd44a7c412.module.wasm # 各种生成的WebAssembly模块文件
|
||||
```
|
||||
|
||||
## 一、安装必要软件
|
||||
|
||||
1. **安装Rust**
|
||||
确保你已安装Rust环境,可以通过以下命令来确认是否安装成功:
|
||||
|
||||
```bash
|
||||
rustc --version
|
||||
```
|
||||
|
||||
2. **安装wasm-pack**
|
||||
`wasm-pack`用于将Rust代码编译为WebAssembly格式。通过以下命令安装:
|
||||
|
||||
```bash
|
||||
cargo install wasm-pack
|
||||
```
|
||||
|
||||
3. **安装npm**
|
||||
npm是JavaScript包管理工具,确保安装最新版本。
|
||||
|
||||
## 二、编写代码
|
||||
|
||||
1. **构建一个新的Rust包**
|
||||
在项目目录下创建一个新的Rust库项目:
|
||||
|
||||
```bash
|
||||
cargo new --lib wasm
|
||||
```
|
||||
|
||||
2. **配置** **`Cargo.toml`**
|
||||
修改`Cargo.toml`来添加WebAssembly的依赖和输出配置:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2.95"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
```
|
||||
|
||||
3. **编写Rust文件** **`src/lib.rs`**
|
||||
使用`wasm-bindgen`库将Rust函数暴露给JavaScript。编写如下代码:
|
||||
|
||||
```rust
|
||||
extern crate wasm_bindgen;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern {
|
||||
pub fn alert(s: &str);
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn greet(name: &str) {
|
||||
alert(&format!("Hello, {}!", name));
|
||||
}
|
||||
```
|
||||
|
||||
4. **将Rust代码编译为WebAssembly**
|
||||
使用`wasm-pack`进行编译:
|
||||
|
||||
```bash
|
||||
wasm-pack build
|
||||
```
|
||||
|
||||
编译后的WebAssembly文件将生成在`pkg/`目录下。
|
||||
|
||||
## 三、打包代码
|
||||
|
||||
1. **初始化npm项目**
|
||||
创建一个`package.json`文件:
|
||||
|
||||
```bash
|
||||
npm init -y
|
||||
```
|
||||
|
||||
2. **安装Webpack及相关工具**
|
||||
安装Webpack及开发服务器,用于打包和本地运行JavaScript与WebAssembly:
|
||||
|
||||
```bash
|
||||
npm install --save-dev webpack webpack-cli webpack-dev-server
|
||||
```
|
||||
|
||||
3. **编写JavaScript调用代码** **`src/index.js`**
|
||||
在`index.js`中,调用由Rust编译出的WebAssembly模块:
|
||||
|
||||
```javascript
|
||||
const js = import("../pkg/wasm");
|
||||
|
||||
js.then((js) => {
|
||||
js.greet("world");
|
||||
});
|
||||
```
|
||||
|
||||
4. **编写Webpack配置文件** **`webpack.config.js`**
|
||||
该配置文件用于打包项目并支持WebAssembly异步加载:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
entry: './src/index.js',
|
||||
mode: 'development', // 开发模式,可切换为'production'
|
||||
experiments: {
|
||||
asyncWebAssembly: true, // 异步加载WebAssembly
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.wasm$/,
|
||||
type: "webassembly/async" // 使用异步加载WebAssembly
|
||||
}
|
||||
]
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
5. **打包JavaScript文件**
|
||||
使用Webpack打包项目:
|
||||
|
||||
```bash
|
||||
npx webpack
|
||||
```
|
||||
|
||||
## 四、运行代码
|
||||
|
||||
1. **编写HTML文件** **`src/index.html`**
|
||||
在HTML文件中引入打包后的`main.js`:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebAssembly Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebAssembly Example</h1>
|
||||
<script src="../dist/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
2. **运行HTML文件**
|
||||
打开`src/index.html`,在浏览器中运行此文件,应该会看到弹窗显示"Hello, world"。
|
@ -4,7 +4,7 @@ date: 2025-03-09T01:07:23Z
|
||||
tags: []
|
||||
---
|
||||
|
||||
这是一个基于 Astro + React 构建的个人博客系统,具有文章管理、项目展示、观影记录、读书记录等功能。本文将详细介绍如何使用和配置这个博客系统。
|
||||
这是一个基于 Astro + React + WASM 构建的个人博客系统,具有文章管理、项目展示、观影记录、读书记录等功能。本文将详细介绍如何使用和配置这个博客系统。
|
||||
|
||||
## 功能特点
|
||||
|
||||
@ -14,8 +14,12 @@ tags: []
|
||||
4. **项目展示**:支持展示 GitHub、Gitea 和 Gitee 的项目
|
||||
5. **观影记录**:集成豆瓣观影数据
|
||||
6. **读书记录**:集成豆瓣读书数据
|
||||
7. **旅行足迹**:支持展示全球旅行足迹热力图
|
||||
7. **旅行足迹**:支持展示全球旅行足迹热力图,WebAssembly 解析地图数据,提高性能
|
||||
8. **丝滑页面过渡**:使用 Swup 集成实现页面间无缝过渡动画,提供类似 SPA 的浏览体验,保留静态站点的所有优势
|
||||
9. **高效搜索与筛选**:使用 Rust 构建二进制数据,WebAssembly 高效解析,提供快速精准的全文搜索体验
|
||||
10. **优化代码块样式**:支持 Mermaid 图表解析,多主题代码高亮
|
||||
11. **优化 SEO**:完整的元数据支持,内置性能优化
|
||||
12. **资源压缩**:支持图片和静态资源压缩,提高加载速度
|
||||
|
||||
## 基础配置
|
||||
|
||||
@ -27,14 +31,37 @@ export const SITE_URL = "https://your-domain.com";
|
||||
export const SITE_NAME = "你的网站名称";
|
||||
export const SITE_DESCRIPTION = "网站描述";
|
||||
|
||||
// 导航链接
|
||||
export const NAV_LINKS = [
|
||||
{ href: "/", text: "首页" },
|
||||
{ href: "/articles", text: "文章" },
|
||||
{ href: "/movies", text: "观影" },
|
||||
{ href: "/books", text: "读书" },
|
||||
{ href: "/projects", text: "项目" },
|
||||
{ href: "/other", text: "其他" },
|
||||
// 导航链接结构 - 支持分层导航
|
||||
export const NAV_STRUCTURE = [
|
||||
{
|
||||
id: 'home',
|
||||
text: '首页',
|
||||
href: '/'
|
||||
},
|
||||
{
|
||||
id: 'douban',
|
||||
text: '豆瓣',
|
||||
items: [
|
||||
{ id: 'movies', text: '观影', href: '/movies' },
|
||||
{ id: 'books', text: '读书', href: '/books' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'articles',
|
||||
text: '文章',
|
||||
items: [
|
||||
{ id: 'filter', text: '筛选', href: '/filtered' },
|
||||
{ id: 'path', text: '文章', href: '/articles' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'others',
|
||||
text: '其他',
|
||||
items: [
|
||||
{ id: 'other', text: '其他', href: '/other' },
|
||||
{ id: 'projects', text: '项目', href: '/projects' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// 备案信息(如果需要)
|
||||
@ -105,15 +132,6 @@ export const ARTICLE_EXPIRY_CONFIG = {
|
||||
};
|
||||
```
|
||||
|
||||
### 文章列表展示
|
||||
|
||||
文章列表页面会自动获取所有文章并按日期排序展示,支持:
|
||||
|
||||
- 文章标题和摘要
|
||||
- 发布日期
|
||||
- 标签系统
|
||||
- 阅读时间估算
|
||||
|
||||
## 项目展示
|
||||
|
||||
项目展示页面支持从 GitHub、Gitea 和 Gitee 获取和展示项目信息。
|
||||
@ -147,7 +165,7 @@ import { GitPlatform } from '@/components/GitProjectCollection';
|
||||
|
||||
`MediaGrid` 组件用于展示豆瓣的观影和读书记录。
|
||||
|
||||
基本用法
|
||||
基本用法:
|
||||
|
||||
```astro
|
||||
---
|
||||
@ -169,13 +187,11 @@ import MediaGrid from '@/components/MediaGrid.astro';
|
||||
/>
|
||||
```
|
||||
|
||||
## 旅行足迹
|
||||
|
||||
### WorldHeatmap 组件
|
||||
## 旅行足迹组件
|
||||
|
||||
`WorldHeatmap` 组件用于展示你去过的地方,以热力图的形式在世界地图上显示。
|
||||
|
||||
基本用法
|
||||
基本用法:
|
||||
|
||||
在 `src/consts.ts` 中配置你去过的地方:
|
||||
|
||||
@ -215,6 +231,57 @@ import { VISITED_PLACES } from '@/consts';
|
||||
</Layout>
|
||||
```
|
||||
|
||||
## 代码块与 Mermaid 图表支持
|
||||
|
||||
博客系统现在支持丰富的代码块样式和 Mermaid 图表渲染:
|
||||
|
||||
### Mermaid 图表支持
|
||||
|
||||
你可以在 Markdown 文件中使用 Mermaid 语法创建各种图表:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
graph TD;
|
||||
A[开始] -->|处理数据| B(处理结果);
|
||||
B --> C{判断条件};
|
||||
C -->|条件1| D[结果1];
|
||||
C -->|条件2| E[结果2];
|
||||
```
|
||||
````
|
||||
|
||||
系统将自动渲染这些图表,并支持深色/浅色主题自动适应。
|
||||
|
||||
### 代码块样式优化
|
||||
|
||||
代码块支持多种主题和语言高亮,内置了复制按钮:
|
||||
|
||||
````markdown
|
||||
```javascript
|
||||
// 示例代码
|
||||
function example() {
|
||||
console.log("Hello, world!");
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
## SEO 优化
|
||||
|
||||
博客系统内置全面的SEO优化支持:
|
||||
|
||||
1. **自动生成元标签**:为每个页面生成适当的标题、描述和Open Graph标签
|
||||
2. **结构化数据**:支持添加结构化数据,提高搜索引擎理解能力
|
||||
3. **站点地图**:自动生成XML站点地图
|
||||
4. **性能优化**:页面加载性能优化,提高搜索排名
|
||||
|
||||
## 资源压缩与优化
|
||||
|
||||
系统支持自动的资源压缩和优化:
|
||||
|
||||
1. **图片优化**:自动压缩和优化图片,支持WebP格式
|
||||
2. **CSS/JS压缩**:自动压缩CSS和JavaScript文件
|
||||
3. **预加载关键资源**:识别并预加载关键资源
|
||||
4. **延迟加载非关键资源**:非关键资源延迟加载,提高初始加载速度
|
||||
|
||||
## 主题切换
|
||||
|
||||
系统支持三种主题模式:
|
||||
@ -225,12 +292,51 @@ import { VISITED_PLACES } from '@/consts';
|
||||
|
||||
主题设置会被保存在浏览器的 localStorage 中。
|
||||
|
||||
## 高效搜索与筛选
|
||||
|
||||
博客系统使用 Rust 构建二进制数据,结合 WebAssembly 高效解析,提供快速的全文搜索和筛选功能。
|
||||
|
||||
### 搜索功能特点
|
||||
|
||||
1. **高性能全文搜索**:使用 Rust 编写的搜索引擎,编译为 WebAssembly 在浏览器中运行
|
||||
2. **智能搜索推荐**:输入时提供智能搜索建议和自动补全
|
||||
3. **拼写纠正**:当用户输入可能存在错误时,提供拼写纠正建议
|
||||
4. **结构化搜索结果**:按标题、内容层次展示匹配内容
|
||||
5. **高亮显示匹配文本**:直观显示匹配位置
|
||||
6. **Tab键补全**:支持使用Tab键快速补全搜索建议
|
||||
|
||||
### 搜索建议类型
|
||||
|
||||
1. **自动补全(Completion)**:当您输入部分词语时,系统会提供以此开头的完整词语或短语
|
||||
- 例如:输入"reac"时,可能会建议"react"、"reactjs"等
|
||||
|
||||
2. **拼写纠正(Correction)**:当您的输入可能有拼写错误时,系统会提供更可能的正确拼写
|
||||
- 例如:输入"javascritp"时,会提示"javascript"
|
||||
|
||||
### 搜索操作指南
|
||||
|
||||
1. **键盘导航**:
|
||||
- 使用上下箭头键在搜索建议之间切换
|
||||
- 使用Tab键或右箭头键接受当前建议
|
||||
- 使用Enter键执行搜索
|
||||
|
||||
2. **建议交互**:
|
||||
- 系统会实时显示最相关的建议
|
||||
- 建议会以淡色显示在您的输入文本后面
|
||||
- 补全建议使用灰色显示,拼写纠正建议使用琥珀色显示
|
||||
|
||||
3. **结果导航**:
|
||||
- 搜索结果会按相关性排序
|
||||
- 滚动到底部自动加载更多结果
|
||||
- 匹配文本会使用黄色高亮显示
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 18+
|
||||
- npm 或 pnpm
|
||||
- Rust (用于开发时修改WASM组件)
|
||||
|
||||
### 安装步骤
|
||||
|
||||
@ -334,3 +440,7 @@ npm run build
|
||||
- 确认是否使用了需要服务器端支持的功能
|
||||
- 检查是否已将动态数据改为静态数据
|
||||
- 确认构建输出目录是否为 `dist/client`
|
||||
|
||||
5. **WebAssembly相关功能无法使用**
|
||||
- 确保浏览器支持WebAssembly
|
||||
- 检查是否启用了内容安全策略(CSP)限制
|
||||
|
@ -1,5 +1,5 @@
|
||||
---
|
||||
title: rust
|
||||
title: 基础语法
|
||||
date: 2024-10-09T18:49:45Z
|
||||
tags: []
|
||||
---
|
202
src/content/理解计算机/rust/用rust实现第一个wasm.md
Normal file
202
src/content/理解计算机/rust/用rust实现第一个wasm.md
Normal file
@ -0,0 +1,202 @@
|
||||
---
|
||||
title: "用Rust实现WebAssembly模块"
|
||||
date: 2024-10-19T15:09:25+08:00
|
||||
tags: ["rust", "webassembly"]
|
||||
---
|
||||
|
||||
## 准备工作
|
||||
|
||||
### 1. 安装Rust
|
||||
|
||||
确保已安装Rust环境:
|
||||
|
||||
```bash
|
||||
rustc --version
|
||||
```
|
||||
|
||||
如果未安装,可以访问[Rust官网](https://www.rust-lang.org/tools/install)按照指引进行安装。
|
||||
|
||||
### 2. 安装wasm-pack
|
||||
|
||||
wasm-pack是将Rust代码编译为WebAssembly的工具:
|
||||
|
||||
```bash
|
||||
cargo install wasm-pack
|
||||
```
|
||||
|
||||
## 创建和构建WebAssembly模块
|
||||
|
||||
### 1. 创建Rust库项目
|
||||
|
||||
```bash
|
||||
cargo new --lib my_wasm
|
||||
cd my_wasm
|
||||
```
|
||||
|
||||
### 2. 配置Cargo.toml
|
||||
|
||||
修改`Cargo.toml`文件,添加必要的依赖和配置:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "my_wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2.95"
|
||||
# 以下是可选依赖,根据项目需求添加
|
||||
js-sys = "0.3.64"
|
||||
web-sys = { version = "0.3.64", features = ["console"] }
|
||||
```
|
||||
|
||||
### 3. 编写Rust代码
|
||||
|
||||
在`src/lib.rs`中编写导出到WebAssembly的代码:
|
||||
|
||||
```rust
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
// 导入JavaScript函数
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
// 导入JavaScript的console.log函数
|
||||
#[wasm_bindgen(js_namespace = console)]
|
||||
fn log(s: &str);
|
||||
}
|
||||
|
||||
// 简单的结构体示例
|
||||
#[wasm_bindgen]
|
||||
pub struct Processor {
|
||||
value: i32,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Processor {
|
||||
// 构造函数
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Processor { value: 0 }
|
||||
}
|
||||
|
||||
// 简单的方法
|
||||
pub fn increment(&mut self, amount: i32) -> i32 {
|
||||
self.value += amount;
|
||||
self.value
|
||||
}
|
||||
|
||||
// 返回处理结果的方法
|
||||
pub fn process_data(&self, input: &str) -> String {
|
||||
log(&format!("Processing data: {}", input));
|
||||
format!("Processed: {} (value={})", input, self.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 单独的函数也可以导出
|
||||
#[wasm_bindgen]
|
||||
pub fn add(a: i32, b: i32) -> i32 {
|
||||
a + b
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 构建WebAssembly模块
|
||||
|
||||
使用wasm-pack构建WebAssembly模块,指定target为web:
|
||||
|
||||
```bash
|
||||
wasm-pack build --target web
|
||||
```
|
||||
|
||||
这将在`pkg/`目录下生成以下文件:
|
||||
|
||||
- `my_wasm.js` - JavaScript包装代码
|
||||
- `my_wasm_bg.wasm` - WebAssembly二进制文件
|
||||
- `my_wasm_bg.js` - JavaScript胶水代码
|
||||
- 其他类型定义和元数据文件
|
||||
|
||||
## 在Web应用中使用WebAssembly模块
|
||||
|
||||
在React组件中使用WebAssembly模块的示例:
|
||||
|
||||
```tsx
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface MyWasmModule {
|
||||
Processor: new () => {
|
||||
increment: (amount: number) => number;
|
||||
process_data: (input: string) => string;
|
||||
};
|
||||
add: (a: number, b: number) => number;
|
||||
default?: () => Promise<any>;
|
||||
}
|
||||
|
||||
const WasmExample: React.FC = () => {
|
||||
const [result, setResult] = useState<string>('');
|
||||
const [wasmModule, setWasmModule] = useState<MyWasmModule | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadWasm = async () => {
|
||||
try {
|
||||
// 动态导入WASM模块
|
||||
const wasm = await import('@/assets/wasm/my_wasm/my_wasm.js');
|
||||
|
||||
// 初始化WASM模块
|
||||
if (typeof wasm.default === 'function') {
|
||||
await wasm.default();
|
||||
}
|
||||
|
||||
setWasmModule(wasm as unknown as MyWasmModule);
|
||||
} catch (err) {
|
||||
console.error('加载WASM模块失败:', err);
|
||||
setError(`WASM模块加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
loadWasm();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wasmModule) return;
|
||||
|
||||
try {
|
||||
// 使用WASM模块中的函数
|
||||
const sum = wasmModule.add(10, 20);
|
||||
console.log(`10 + 20 = ${sum}`);
|
||||
|
||||
// 使用WASM模块中的类
|
||||
const processor = new wasmModule.Processor();
|
||||
processor.increment(15);
|
||||
const processResult = processor.process_data("React与WASM");
|
||||
|
||||
setResult(processResult);
|
||||
} catch (err) {
|
||||
console.error('使用WASM模块失败:', err);
|
||||
setError(`WASM操作失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [wasmModule]);
|
||||
|
||||
if (error) {
|
||||
return <div className="error-message">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wasm-example">
|
||||
<h2>WebAssembly示例</h2>
|
||||
{!wasmModule ? (
|
||||
<p>正在加载WASM模块...</p>
|
||||
) : (
|
||||
<div>
|
||||
<p>WASM处理结果:</p>
|
||||
<pre>{result}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WasmExample;
|
||||
```
|
@ -1,14 +1,10 @@
|
||||
---
|
||||
import Layout from '@/components/Layout.astro';
|
||||
import { SITE_NAME } from '@/consts';
|
||||
|
||||
// 启用静态预渲染
|
||||
export const prerender = true;
|
||||
|
||||
---
|
||||
|
||||
<Layout title={`404 - 页面未找到`}>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center">
|
||||
<Layout title={`404 - 页面未找到`} description={`您访问的页面不存在或已被移动到其他位置`} skipSrTitle={false}>
|
||||
|
||||
v class="min-h-[calc(100vh-4rem)] flex items-center justify-center">
|
||||
<div class="text-center px-4">
|
||||
<h1 class="text-6xl md:text-8xl font-bold bg-gradient-to-r from-primary-600 to-primary-400 dark:from-primary-400 dark:to-primary-200 text-transparent bg-clip-text mb-6">
|
||||
404
|
||||
|
@ -1,63 +0,0 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { getSpecialPath } from '../../content.config';
|
||||
|
||||
// 从文章内容中提取摘要的函数
|
||||
function extractSummary(content: string, length = 150) {
|
||||
// 移除 Markdown 标记
|
||||
const plainText = content
|
||||
.replace(/---[\s\S]*?---/, '') // 移除 frontmatter
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // 将链接转换为纯文本
|
||||
.replace(/[#*`~>]/g, '') // 移除特殊字符
|
||||
.replace(/\n+/g, ' ') // 将换行转换为空格
|
||||
.trim();
|
||||
|
||||
return plainText.length > length
|
||||
? plainText.slice(0, length).trim() + '...'
|
||||
: plainText;
|
||||
}
|
||||
|
||||
// 处理特殊ID的函数
|
||||
function getArticleUrl(articleId: string) {
|
||||
return `/articles/${getSpecialPath(articleId)}`;
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// 获取所有文章
|
||||
const articles = await getCollection('articles');
|
||||
// 格式化文章数据
|
||||
const formattedArticles = articles.map(article => ({
|
||||
id: article.id,
|
||||
title: article.data.title,
|
||||
date: article.data.date,
|
||||
tags: article.data.tags || [],
|
||||
summary: article.data.summary || (article.body ? extractSummary(article.body) : ''),
|
||||
url: getArticleUrl(article.id) // 使用特殊ID处理函数
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
articles: formattedArticles,
|
||||
total: formattedArticles.length,
|
||||
success: true
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// 添加缓存头,缓存1小时
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({
|
||||
error: '获取文章数据失败',
|
||||
success: false,
|
||||
articles: [],
|
||||
total: 0
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,6 @@
|
||||
---
|
||||
import ArticlesPage from './index.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
// 启用静态预渲染
|
||||
export const prerender = true;
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
|
||||
// 获取目录结构
|
||||
export async function getStaticPaths() {
|
||||
@ -12,7 +9,7 @@ export async function getStaticPaths() {
|
||||
// 从文章ID中提取所有目录路径
|
||||
const directories = new Set<string>();
|
||||
|
||||
articles.forEach(article => {
|
||||
articles.forEach((article: CollectionEntry<'articles'>) => {
|
||||
if (article.id.includes('/')) {
|
||||
// 获取所有层级的目录
|
||||
const parts = article.id.split('/');
|
||||
|
@ -69,167 +69,165 @@ const pageTitle = currentPath ? currentPath : '文章列表';
|
||||
|
||||
---
|
||||
|
||||
<Layout title={`${pageTitle}`}>
|
||||
<div class="bg-gray-50 dark:bg-dark-bg min-h-screen">
|
||||
<main class="mx-auto px-4 sm:px-6 lg:px-8 py-6 max-w-7xl">
|
||||
<!-- 页面标题 -->
|
||||
<h1 class="sr-only">{pageTitle}</h1>
|
||||
|
||||
<!-- 导航栏 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl mb-4 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-4 py-3">
|
||||
<Breadcrumb
|
||||
pageType="grid"
|
||||
pathSegments={pathSegments}
|
||||
path={currentPath}
|
||||
/>
|
||||
</div>
|
||||
<Layout title={`${pageTitle}`} pageType="directory">
|
||||
<!-- 传递head槽位 -->
|
||||
<slot name="head" slot="head" />
|
||||
|
||||
<div class="py-6 w-full">
|
||||
<!-- 导航栏 -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl mb-4 shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div class="px-4 py-3">
|
||||
<Breadcrumb
|
||||
pageType="grid"
|
||||
pathSegments={pathSegments}
|
||||
path={currentPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容卡片网格 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||
{/* 上一级目录卡片 - 仅在浏览目录时显示 */}
|
||||
{pathSegments.length > 0 && (
|
||||
<a href={`/articles/${pathSegments.length > 1 ? pathSegments.slice(0, -1).join('/') : ''}/`}
|
||||
class="group flex flex-col h-full p-5 border border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:shadow-xl hover:-translate-y-1 shadow-lg"
|
||||
data-astro-prefetch="hover">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 flex items-center justify-center rounded-lg bg-primary-100 text-primary-600 group-hover:bg-primary-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<div class="font-bold text-base text-gray-800 dark:text-gray-100 group-hover:text-primary-700 dark:group-hover:text-primary-300">返回上级目录</div>
|
||||
<div class="text-xs text-gray-500">返回上一级</div>
|
||||
</div>
|
||||
<div class="text-primary-500 opacity-0 group-hover:opacity-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<!-- 内容卡片网格 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||
{/* 上一级目录卡片 - 仅在浏览目录时显示 */}
|
||||
{pathSegments.length > 0 && (
|
||||
<a href={`/articles/${pathSegments.length > 1 ? pathSegments.slice(0, -1).join('/') : ''}/`}
|
||||
{/* 目录卡片 */}
|
||||
{currentSections.map(section => {
|
||||
// 确保目录链接正确生成
|
||||
const dirLink = currentPath ? `${currentPath}/${section.name}` : section.name;
|
||||
|
||||
return (
|
||||
<a href={`/articles/${dirLink}/`}
|
||||
class="group flex flex-col h-full p-5 border border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:shadow-xl hover:-translate-y-1 shadow-lg"
|
||||
data-astro-prefetch="hover">
|
||||
data-astro-prefetch="viewport">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 flex items-center justify-center rounded-lg bg-primary-100 text-primary-600 group-hover:bg-primary-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<div class="font-bold text-base text-gray-800 dark:text-gray-100 group-hover:text-primary-700 dark:group-hover:text-primary-300">返回上级目录</div>
|
||||
<div class="text-xs text-gray-500">返回上一级</div>
|
||||
<div class="font-bold text-base text-gray-800 dark:text-gray-100 group-hover:text-primary-700 dark:group-hover:text-primary-300 line-clamp-1">{section.name}</div>
|
||||
<div class="text-xs text-gray-500 flex items-center mt-1">
|
||||
{section.sections.length > 0 && (
|
||||
<span class="flex items-center mr-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
{section.sections.length} 个子目录
|
||||
</span>
|
||||
)}
|
||||
{section.articles.length > 0 && (
|
||||
<span class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{section.articles.length} 篇文章
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-primary-500 opacity-0 group-hover:opacity-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 文章卡片 */}
|
||||
{currentArticles.map(articlePath => {
|
||||
// 获取文章ID - 不需要移除src/content前缀,因为contentStructure中已经是相对路径
|
||||
const articleId = articlePath;
|
||||
|
||||
{/* 目录卡片 */}
|
||||
{currentSections.map(section => {
|
||||
// 确保目录链接正确生成
|
||||
const dirLink = currentPath ? `${currentPath}/${section.name}` : section.name;
|
||||
|
||||
// 尝试匹配文章
|
||||
const article = articles.find(a => a.id === articleId);
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<a href={`/articles/${dirLink}/`}
|
||||
class="group flex flex-col h-full p-5 border border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:shadow-xl hover:-translate-y-1 shadow-lg"
|
||||
data-astro-prefetch="viewport">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 flex items-center justify-center rounded-lg bg-primary-100 text-primary-600 group-hover:bg-primary-200">
|
||||
<div class="flex flex-col h-full p-5 border border-red-200 rounded-xl bg-red-50 shadow-lg">
|
||||
<div class="flex items-start">
|
||||
<div class="w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-lg bg-red-100 text-red-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<div class="font-bold text-base text-gray-800 dark:text-gray-100 group-hover:text-primary-700 dark:group-hover:text-primary-300 line-clamp-1">{section.name}</div>
|
||||
<div class="text-xs text-gray-500 flex items-center mt-1">
|
||||
{section.sections.length > 0 && (
|
||||
<span class="flex items-center mr-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
{section.sections.length} 个子目录
|
||||
</span>
|
||||
)}
|
||||
{section.articles.length > 0 && (
|
||||
<span class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{section.articles.length} 篇文章
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 class="font-bold text-base text-red-800">文章不存在</h3>
|
||||
<p class="text-xs text-red-600 mt-1">
|
||||
<div>原始路径: {articlePath}</div>
|
||||
<div>文章ID: {articleId}</div>
|
||||
<div>当前目录: {currentPath}</div>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-primary-500 opacity-0 group-hover:opacity-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="article-card">
|
||||
<a href={`/articles/${article.id}`}
|
||||
class="article-card-link"
|
||||
data-astro-prefetch="viewport">
|
||||
<div class="article-card-content">
|
||||
<div class="article-card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="article-card-body">
|
||||
<h3 class="article-card-title">{article.data.title}</h3>
|
||||
{article.body && (
|
||||
<p class="article-card-summary">
|
||||
{article.data.summary}
|
||||
</p>
|
||||
)}
|
||||
<div class="article-card-footer">
|
||||
<time datetime={article.data.date.toISOString()} class="article-card-date">
|
||||
{article.data.date.toLocaleDateString('zh-CN', {year: 'numeric', month: 'long', day: 'numeric'})}
|
||||
</time>
|
||||
<span class="article-card-read-more">阅读全文</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 文章卡片 */}
|
||||
{currentArticles.map(articlePath => {
|
||||
// 获取文章ID - 不需要移除src/content前缀,因为contentStructure中已经是相对路径
|
||||
const articleId = articlePath;
|
||||
|
||||
// 尝试匹配文章
|
||||
const article = articles.find(a => a.id === articleId);
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<div class="flex flex-col h-full p-5 border border-red-200 rounded-xl bg-red-50 shadow-lg">
|
||||
<div class="flex items-start">
|
||||
<div class="w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-lg bg-red-100 text-red-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<h3 class="font-bold text-base text-red-800">文章不存在</h3>
|
||||
<p class="text-xs text-red-600 mt-1">
|
||||
<div>原始路径: {articlePath}</div>
|
||||
<div>文章ID: {articleId}</div>
|
||||
<div>当前目录: {currentPath}</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="group flex flex-col h-full border border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 hover:shadow-xl hover:-translate-y-1 shadow-lg recent-article">
|
||||
<a href={`/articles/${article.id}`}
|
||||
class="p-5 block flex-grow"
|
||||
data-astro-prefetch="viewport">
|
||||
<div class="flex items-start">
|
||||
<div class="w-10 h-10 flex-shrink-0 flex items-center justify-center rounded-lg bg-primary-100 text-primary-600 group-hover:bg-primary-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3 flex-1 min-w-0">
|
||||
<h3 class="font-bold text-base text-gray-800 dark:text-gray-100 group-hover:text-primary-700 dark:group-hover:text-primary-300 line-clamp-2">{article.data.title}</h3>
|
||||
{article.body && (
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2 break-words">
|
||||
{article.data.summary}
|
||||
</p>
|
||||
)}
|
||||
<div class="text-xs text-gray-500 mt-2 flex items-center justify-between">
|
||||
<time datetime={article.data.date.toISOString()}>
|
||||
{article.data.date.toLocaleDateString('zh-CN', {year: 'numeric', month: 'long', day: 'numeric'})}
|
||||
</time>
|
||||
<span class="text-primary-600 font-medium truncate ml-2">阅读全文</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 空内容提示 */}
|
||||
{(currentSections.length === 0 && currentArticles.length === 0) && (
|
||||
<div class="text-center py-16 bg-white rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 mb-12">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-primary-200 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-bold text-gray-700 mb-2">此目录为空</h3>
|
||||
<p class="text-gray-500 max-w-md mx-auto">此目录下暂无内容,请浏览其他目录或返回上一级</p>
|
||||
</div>
|
||||
|
||||
{/* 空内容提示 */}
|
||||
{(currentSections.length === 0 && currentArticles.length === 0) && (
|
||||
<div class="text-center py-16 bg-white rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 mb-12">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-primary-200 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<h3 class="text-2xl font-bold text-gray-700 mb-2">此目录为空</h3>
|
||||
<p class="text-gray-500 max-w-md mx-auto">此目录下暂无内容,请浏览其他目录或返回上一级</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
@ -4,12 +4,16 @@ import MediaGrid from "@/components/MediaGrid.tsx";
|
||||
import { DOUBAN_ID } from "@/consts";
|
||||
---
|
||||
|
||||
<Layout title={`豆瓣图书`}>
|
||||
<Layout
|
||||
title={`豆瓣图书`}
|
||||
description={`我读过的书`}
|
||||
skipSrTitle={false}
|
||||
>
|
||||
<h1 class="text-3xl font-bold mb-6">我读过的书</h1>
|
||||
|
||||
<MediaGrid
|
||||
type="book"
|
||||
title="我读过的书"
|
||||
doubanId={DOUBAN_ID}
|
||||
client:load
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
|
31
src/pages/filtered.astro
Normal file
31
src/pages/filtered.astro
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
import Layout from "@/components/Layout.astro";
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro";
|
||||
import ArticleFilter from "@/components/ArticleFilter";
|
||||
|
||||
// 获取当前URL的查询参数
|
||||
const searchParams = Astro.url.searchParams;
|
||||
---
|
||||
|
||||
<Layout title="文章筛选">
|
||||
<div class="w-full">
|
||||
<!-- 导航栏 -->
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-xl mb-4 shadow-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="px-4 py-3">
|
||||
<Breadcrumb
|
||||
pageType="filter"
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用ArticleFilter组件 -->
|
||||
<ArticleFilter
|
||||
searchParams={searchParams}
|
||||
client:load
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,19 @@
|
||||
---
|
||||
import Layout from "@/components/Layout.astro";
|
||||
import MediaGrid from "@/components/MediaGrid.tsx";
|
||||
import { SITE_NAME, DOUBAN_ID } from "@/consts.ts";
|
||||
import { DOUBAN_ID } from "@/consts.ts";
|
||||
---
|
||||
|
||||
<Layout title={`电影`}>
|
||||
<Layout
|
||||
title={`电影`}
|
||||
description={`我看过的一些电影`}
|
||||
skipSrTitle={false}
|
||||
>
|
||||
<h1 class="text-3xl font-bold mb-6">我看过的电影</h1>
|
||||
|
||||
<MediaGrid
|
||||
type="movie"
|
||||
title="我看过的电影"
|
||||
doubanId={DOUBAN_ID}
|
||||
client:load
|
||||
/>
|
||||
</Layout>
|
||||
|
||||
|
@ -7,7 +7,6 @@ import { VISITED_PLACES } from '@/consts';
|
||||
|
||||
<Layout title="其他">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-4xl font-bold mb-8 text-center">其他内容</h1>
|
||||
<section class="mb-16">
|
||||
<h2 class="text-3xl font-semibold text-center mb-6">距离退休还有</h2>
|
||||
<div class="max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 hover:shadow-xl">
|
||||
|
@ -6,7 +6,6 @@ import { GitPlatform } from '@/components/GitProjectCollection';
|
||||
|
||||
<Layout title="项目">
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-4xl font-bold mb-8 text-center">项目</h1>
|
||||
<div class="space-y-12">
|
||||
<GitProjectCollection
|
||||
platform={GitPlatform.GITEA}
|
||||
|
205
src/plugins/build-article-index.js
Normal file
205
src/plugins/build-article-index.js
Normal file
@ -0,0 +1,205 @@
|
||||
// build-article-index.js
|
||||
// 在Astro构建完成后生成文章索引的脚本
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
// 获取当前文件的目录
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// 获取项目根目录
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
// 构建目录在根目录下
|
||||
const buildDir = path.resolve(rootDir, 'dist');
|
||||
// 索引文件存储位置
|
||||
const indexDir = path.join(buildDir, 'client', 'index');
|
||||
|
||||
// 二进制可执行文件路径
|
||||
const binaryPath = path.join(rootDir, 'src', 'assets', 'article-index', process.platform === 'win32'
|
||||
? 'article-indexer-cli.exe'
|
||||
: 'article-indexer-cli');
|
||||
|
||||
/**
|
||||
* 创建Astro构建后钩子插件,用于生成文章索引
|
||||
* @returns {import('astro').AstroIntegration} Astro集成对象
|
||||
*/
|
||||
export function articleIndexerIntegration() {
|
||||
return {
|
||||
name: 'article-indexer-integration',
|
||||
hooks: {
|
||||
// 开发服务器钩子 - 为开发模式添加虚拟API路由
|
||||
'astro:server:setup': ({ server }) => {
|
||||
// 为index目录下的文件提供虚拟API路由
|
||||
server.middlewares.use((req, res, next) => {
|
||||
// 检查请求路径是否是索引文件
|
||||
if (req.url.startsWith('/index/') && req.method === 'GET') {
|
||||
const requestedFile = req.url.slice(7); // 移除 '/index/'
|
||||
const filePath = path.join(indexDir, requestedFile);
|
||||
|
||||
console.log(`虚拟API请求: ${req.url} -> ${filePath}`);
|
||||
|
||||
// 检查文件是否存在
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.isFile()) {
|
||||
// 设置适当的Content-Type
|
||||
let contentType = 'application/octet-stream';
|
||||
if (filePath.endsWith('.json')) {
|
||||
contentType = 'application/json';
|
||||
} else if (filePath.endsWith('.bin')) {
|
||||
contentType = 'application/octet-stream';
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 文件不存在,返回404
|
||||
res.statusCode = 404;
|
||||
res.end('索引文件未找到');
|
||||
return;
|
||||
}
|
||||
|
||||
// 不是索引文件请求,继续下一个中间件
|
||||
next();
|
||||
});
|
||||
},
|
||||
'astro:build:done': async ({ dir, pages }) => {
|
||||
console.log('Astro构建完成,开始生成文章索引...');
|
||||
|
||||
// 获取构建目录路径
|
||||
let buildDirPath;
|
||||
|
||||
// 直接处理URL对象
|
||||
if (dir instanceof URL) {
|
||||
buildDirPath = dir.pathname;
|
||||
// Windows路径修复
|
||||
if (process.platform === 'win32' && buildDirPath.startsWith('/') && /^\/[A-Z]:/i.test(buildDirPath)) {
|
||||
buildDirPath = buildDirPath.substring(1);
|
||||
}
|
||||
} else {
|
||||
buildDirPath = String(dir);
|
||||
}
|
||||
|
||||
// 确定客户端输出目录
|
||||
let clientDirPath = buildDirPath;
|
||||
const clientSuffix = path.sep + 'client';
|
||||
|
||||
if (buildDirPath.endsWith(clientSuffix)) {
|
||||
clientDirPath = buildDirPath;
|
||||
} else if (fs.existsSync(path.join(buildDirPath, 'client'))) {
|
||||
clientDirPath = path.join(buildDirPath, 'client');
|
||||
}
|
||||
|
||||
// 索引输出目录
|
||||
const outputDirPath = path.join(clientDirPath, 'index');
|
||||
|
||||
await generateArticleIndex({
|
||||
buildDir: clientDirPath,
|
||||
outputDir: outputDirPath
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文章索引
|
||||
* 使用二进制可执行文件直接扫描HTML目录并生成索引
|
||||
* @param {Object} options - 选项对象
|
||||
* @param {string} options.buildDir - 构建输出目录
|
||||
* @param {string} options.outputDir - 索引输出目录
|
||||
* @returns {Promise<Object>} 索引生成结果
|
||||
*/
|
||||
export async function generateArticleIndex(options = {}) {
|
||||
console.log('开始生成文章索引...');
|
||||
|
||||
try {
|
||||
// 使用提供的目录或默认目录
|
||||
const buildDirPath = options.buildDir || buildDir;
|
||||
const outputDirPath = options.outputDir || indexDir;
|
||||
|
||||
console.log(`构建目录: ${buildDirPath}`);
|
||||
console.log(`索引输出目录: ${outputDirPath}`);
|
||||
|
||||
// 确保索引目录存在
|
||||
if (!fs.existsSync(outputDirPath)) {
|
||||
console.log(`创建索引输出目录: ${outputDirPath}`);
|
||||
fs.mkdirSync(outputDirPath, { recursive: true });
|
||||
}
|
||||
|
||||
// 检查二进制文件是否存在
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`索引工具不存在: ${binaryPath}`);
|
||||
}
|
||||
|
||||
// 检查构建目录是否存在
|
||||
if (!fs.existsSync(buildDirPath)) {
|
||||
throw new Error(`构建目录不存在: ${buildDirPath}`);
|
||||
}
|
||||
|
||||
// 设置二进制可执行文件权限(仅Unix系统)
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
}
|
||||
|
||||
try {
|
||||
// 执行索引命令,直接捕获输出
|
||||
const result = execFileSync(binaryPath, [
|
||||
'--source', // 源目录参数名
|
||||
buildDirPath, // 源目录值
|
||||
'--output', // 输出目录参数名
|
||||
outputDirPath, // 输出目录值
|
||||
'--verbose', // 输出详细日志
|
||||
// '--all' // 索引所有页面类型
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
// 在Windows上禁用引号转义,防止参数解析问题
|
||||
windowsVerbatimArguments: process.platform === 'win32'
|
||||
});
|
||||
|
||||
console.log(result);
|
||||
console.log('文章索引生成完成!');
|
||||
console.log(`索引文件保存在: ${outputDirPath}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
indexPath: outputDirPath
|
||||
};
|
||||
} catch (execError) {
|
||||
console.error('执行索引工具时出错:', execError.message);
|
||||
if (execError.stdout) console.log('标准输出:', execError.stdout);
|
||||
if (execError.stderr) console.log('错误输出:', execError.stderr);
|
||||
|
||||
// 尝试直接读取构建目录内容并打印,帮助调试
|
||||
try {
|
||||
console.log(`构建目录内容 (${buildDirPath}):`);
|
||||
const items = fs.readdirSync(buildDirPath);
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(buildDirPath, item);
|
||||
const stats = fs.statSync(itemPath);
|
||||
console.log(`- ${item} (${stats.isDirectory() ? '目录' : '文件'}, ${stats.size} 字节)`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
console.error('无法读取构建目录内容:', fsError.message);
|
||||
}
|
||||
|
||||
throw execError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成文章索引时出错:', error.message);
|
||||
|
||||
// 更详细的错误信息
|
||||
if (error.stdout) console.log('标准输出:', error.stdout);
|
||||
if (error.stderr) console.log('错误输出:', error.stderr);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
1076
src/plugins/custom-code-blocks.js
Normal file
1076
src/plugins/custom-code-blocks.js
Normal file
File diff suppressed because it is too large
Load Diff
537
src/styles/code-blocks.css
Normal file
537
src/styles/code-blocks.css
Normal file
@ -0,0 +1,537 @@
|
||||
/* 代码块容器 */
|
||||
.code-block-container {
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
background-color: #f5f7fa;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .code-block-container {
|
||||
background-color: #282a36;
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.code-block-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #e2e8f0;
|
||||
border-bottom: 1px solid #cbd5e0;
|
||||
font-family: 'JetBrains Mono', Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .code-block-title {
|
||||
background-color: #343746;
|
||||
border-bottom: 1px solid #44475a;
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
/* 标题栏左侧 */
|
||||
.code-title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 标题栏右侧 */
|
||||
.code-title-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 语言标识 */
|
||||
.code-language {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .code-language {
|
||||
color: #bd93f9;
|
||||
}
|
||||
|
||||
/* 文件名 */
|
||||
.code-filename {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #4a5568;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .code-filename {
|
||||
color: #f1fa8c;
|
||||
}
|
||||
|
||||
/* 复制按钮 */
|
||||
.copy-button {
|
||||
background: transparent;
|
||||
border: 1px solid #a0aec0;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: #edf2f7;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .copy-button {
|
||||
border-color: #44475a;
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .copy-button:hover {
|
||||
background: #44475a;
|
||||
}
|
||||
|
||||
/* 代码内容区 */
|
||||
.code-block-content {
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.code-block-content pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
font-family: 'JetBrains Mono', Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.code-block-content code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ----------------------- */
|
||||
/* 终端样式 */
|
||||
/* ----------------------- */
|
||||
|
||||
.terminal-container {
|
||||
background-color: #f8fafc;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid #adc2ff;
|
||||
box-shadow: 0 8px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
[data-theme="dark"] .terminal-container {
|
||||
background-color: #1a1e2a;
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 终端标题栏 */
|
||||
.terminal-container .code-block-title {
|
||||
background-color: #f1f5f9;
|
||||
border-bottom: 1px solid #adc2ff;
|
||||
padding: 0.4rem 1rem;
|
||||
color: #334155;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container .code-block-title {
|
||||
background-color: #111827;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
/* 终端控制按钮 */
|
||||
.terminal-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.terminal-control {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.terminal-close {
|
||||
background-color: #f87171;
|
||||
}
|
||||
|
||||
.terminal-minimize {
|
||||
background-color: #fbbf24;
|
||||
}
|
||||
|
||||
.terminal-maximize {
|
||||
background-color: #34d399;
|
||||
}
|
||||
|
||||
/* 终端内容区 */
|
||||
.terminal-container .code-block-content {
|
||||
background-color: #f8fafc;
|
||||
color: #334155;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container .code-block-content {
|
||||
background-color: #1a1e2a;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.terminal-pre {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-pre {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
/* 语言标签在终端样式中的颜色 */
|
||||
.terminal-container .code-language {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container .code-language {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
/* 终端中的复制按钮 */
|
||||
.terminal-container .copy-button {
|
||||
border-color: #adc2ff;
|
||||
color: #4a5568;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.terminal-container .copy-button:hover {
|
||||
background: #ebf0ff;
|
||||
color: #4b6bff;
|
||||
border-color: #4b6bff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container .copy-button {
|
||||
border-color: rgba(255,255,255,0.3);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container .copy-button:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ----------------------- */
|
||||
/* Mermaid 图表样式 */
|
||||
/* ----------------------- */
|
||||
|
||||
.mermaid-figure {
|
||||
margin: 2rem auto;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mermaid-figure img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 亮色主题图表 */
|
||||
.light-theme-diagram {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .light-theme-diagram {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 暗色主题图表 */
|
||||
.dark-theme-diagram {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dark-theme-diagram {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mermaid 错误样式 */
|
||||
.mermaid-error {
|
||||
border: 1px solid #e53e3e;
|
||||
background-color: #fff5f5;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-error {
|
||||
background-color: #3b1a1a;
|
||||
border-color: #fc8181;
|
||||
}
|
||||
|
||||
.mermaid-error-message {
|
||||
color: #c53030;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-error-message {
|
||||
color: #fc8181;
|
||||
}
|
||||
|
||||
/* ----------------------- */
|
||||
/* Highlight.js 主题样式 */
|
||||
/* ----------------------- */
|
||||
|
||||
/* 基本颜色方案 - 亮色 */
|
||||
.hljs {
|
||||
color: #1a202c;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs {
|
||||
color: #f8f8f2;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 注释 */
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #718096;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-comment,
|
||||
[data-theme="dark"] .hljs-quote {
|
||||
color: #6272a4;
|
||||
}
|
||||
|
||||
/* 关键字 */
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-addition {
|
||||
color: #805ad5;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-keyword,
|
||||
[data-theme="dark"] .hljs-selector-tag,
|
||||
[data-theme="dark"] .hljs-addition {
|
||||
color: #ff79c6;
|
||||
}
|
||||
|
||||
/* 变量名和属性 */
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-literal,
|
||||
.hljs-attr,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-meta {
|
||||
color: #dd6b20;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-variable,
|
||||
[data-theme="dark"] .hljs-template-variable,
|
||||
[data-theme="dark"] .hljs-literal,
|
||||
[data-theme="dark"] .hljs-attr,
|
||||
[data-theme="dark"] .hljs-selector-attr,
|
||||
[data-theme="dark"] .hljs-selector-pseudo,
|
||||
[data-theme="dark"] .hljs-meta {
|
||||
color: #f1fa8c;
|
||||
}
|
||||
|
||||
/* 函数名 */
|
||||
.hljs-title,
|
||||
.hljs-function .hljs-title,
|
||||
.hljs-section {
|
||||
color: #3182ce;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-title,
|
||||
[data-theme="dark"] .hljs-function .hljs-title,
|
||||
[data-theme="dark"] .hljs-section {
|
||||
color: #50fa7b;
|
||||
}
|
||||
|
||||
/* 类名和ID */
|
||||
.hljs-title.class_,
|
||||
.hljs-type,
|
||||
.hljs-class .hljs-title,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #d53f8c;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-title.class_,
|
||||
[data-theme="dark"] .hljs-type,
|
||||
[data-theme="dark"] .hljs-class .hljs-title,
|
||||
[data-theme="dark"] .hljs-selector-id,
|
||||
[data-theme="dark"] .hljs-selector-class {
|
||||
color: #8be9fd;
|
||||
}
|
||||
|
||||
/* 字符串 */
|
||||
.hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-attribute,
|
||||
.hljs-doctag {
|
||||
color: #38a169;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-string,
|
||||
[data-theme="dark"] .hljs-regexp,
|
||||
[data-theme="dark"] .hljs-attribute,
|
||||
[data-theme="dark"] .hljs-doctag {
|
||||
color: #f1fa8c;
|
||||
}
|
||||
|
||||
/* 数字 */
|
||||
.hljs-number,
|
||||
.hljs-deletion,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-link,
|
||||
.hljs-formula {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-number,
|
||||
[data-theme="dark"] .hljs-deletion,
|
||||
[data-theme="dark"] .hljs-symbol,
|
||||
[data-theme="dark"] .hljs-bullet,
|
||||
[data-theme="dark"] .hljs-link,
|
||||
[data-theme="dark"] .hljs-formula {
|
||||
color: #bd93f9;
|
||||
}
|
||||
|
||||
/* HTML标签 */
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-selector-tag {
|
||||
color: #2b6cb0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-tag,
|
||||
[data-theme="dark"] .hljs-name,
|
||||
[data-theme="dark"] .hljs-selector-tag {
|
||||
color: #ff79c6;
|
||||
}
|
||||
|
||||
/* 内置对象 */
|
||||
.hljs-built_in {
|
||||
color: #c05621;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-built_in {
|
||||
color: #8be9fd;
|
||||
}
|
||||
|
||||
/* 高亮代码行 */
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 终端命令特殊着色 */
|
||||
.terminal-container .hljs-built_in,
|
||||
.terminal-container .hljs-keyword {
|
||||
color: #4b6bff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container .hljs-built_in,
|
||||
[data-theme="dark"] .terminal-container .hljs-keyword {
|
||||
color: #9ae6b4;
|
||||
}
|
||||
|
||||
/* 终端中的字符串 */
|
||||
.terminal-container .hljs-string {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container .hljs-string {
|
||||
color: #fbd38d;
|
||||
}
|
||||
|
||||
/* 终端中的变量 */
|
||||
.terminal-container .hljs-variable {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container .hljs-variable {
|
||||
color: #b794f4;
|
||||
}
|
||||
|
||||
/* 终端中的参数 */
|
||||
.terminal-container .hljs-params {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container .hljs-params {
|
||||
color: #cbd5e0;
|
||||
}
|
||||
|
||||
/* 终端中的注释 */
|
||||
.terminal-container .hljs-comment {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .terminal-container .hljs-comment {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.code-block-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.code-block-content::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-block-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-block-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .code-block-content::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .code-block-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .code-block-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
@import "./table-styles.css";
|
||||
@import "./code-blocks.css";
|
||||
@import "./mermaid-themes.css";
|
||||
|
||||
/* 增强列表样式 */
|
||||
.prose ul {
|
||||
|
@ -3,11 +3,6 @@
|
||||
/* 定义深色模式选择器 */
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
/* 为所有元素添加统一的主题过渡效果 */
|
||||
* {
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
@theme {
|
||||
/* 主色调 - 使用更现代的蓝紫色 */
|
||||
@ -122,4 +117,136 @@
|
||||
|
||||
[data-theme='dark'] * {
|
||||
scrollbar-color: var(--scrollbar-dark-thumb) var(--scrollbar-dark-track);
|
||||
}
|
||||
|
||||
/* 统一的文章卡片样式 - 纯CSS实现 */
|
||||
.article-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--color-gray-200);
|
||||
border-radius: 0.75rem;
|
||||
background-color: white;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.article-card:hover {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
transform: translateY(-0.25rem);
|
||||
}
|
||||
|
||||
/* 黑暗模式卡片样式 */
|
||||
[data-theme='dark'] .article-card {
|
||||
background-color: var(--color-gray-800);
|
||||
border-color: var(--color-gray-700);
|
||||
}
|
||||
|
||||
.article-card-link {
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.article-card-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.article-card-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--color-primary-100);
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .article-card-icon {
|
||||
background-color: rgba(75, 107, 255, 0.2);
|
||||
color: var(--color-primary-400);
|
||||
}
|
||||
|
||||
.article-card:hover .article-card-icon {
|
||||
background-color: var(--color-primary-200);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .article-card:hover .article-card-icon {
|
||||
background-color: rgba(75, 107, 255, 0.3);
|
||||
}
|
||||
|
||||
.article-card-body {
|
||||
margin-left: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-card-title {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--color-gray-800);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .article-card-title {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
|
||||
.article-card:hover .article-card-title {
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .article-card:hover .article-card-title {
|
||||
color: var(--color-primary-300);
|
||||
}
|
||||
|
||||
.article-card-summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-gray-600);
|
||||
margin-top: 0.25rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .article-card-summary {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
.article-card-footer {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-gray-500);
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.article-card-date {
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .article-card-date {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
.article-card-read-more {
|
||||
color: var(--color-primary-600);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .article-card-read-more {
|
||||
color: var(--color-primary-400);
|
||||
}
|
@ -15,174 +15,49 @@
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 通用搜索框样式 */
|
||||
.pagefind-ui .pagefind-ui__form {
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
position: relative; /* 确保定位上下文 */
|
||||
/* 添加导航高亮背景的过渡动画 */
|
||||
#nav-primary-highlight,
|
||||
#nav-secondary-highlight {
|
||||
transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.pagefind-ui .pagefind-ui__search-input {
|
||||
border-radius: 9999px !important;
|
||||
font-size: 0.875rem !important;
|
||||
height: 32px !important;
|
||||
width: 12rem !important;
|
||||
padding-top: 0.25rem !important;
|
||||
padding-bottom: 0.25rem !important;
|
||||
padding-left: 35px !important;
|
||||
padding-right: 47px !important;
|
||||
border-color: var(--color-gray-200) !important;
|
||||
border-width: 1px !important;
|
||||
color: var(--color-gray-700) !important;
|
||||
font-weight: 400 !important;
|
||||
background-color: var(--color-gray-50) !important;
|
||||
/* 改进子菜单的动画效果 */
|
||||
.nav-group-items {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* 移动端搜索框样式 */
|
||||
#mobile-search-panel .pagefind-ui .pagefind-ui__search-input {
|
||||
width: 100% !important;
|
||||
height:35px!important;
|
||||
.nav-group-items.menu-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
#mobile-search-panel .pagefind-ui__search-input {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
/* 二级菜单展开状态 */
|
||||
.nav-group-items.menu-visible {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
z-index: 21;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pagefind-ui .pagefind-ui__search-input {
|
||||
color: var(--color-gray-200) !important;
|
||||
border-color: var(--color-gray-700) !important;
|
||||
background-color: var(--color-gray-800) !important;
|
||||
/* 一级菜单按钮在二级菜单展开时隐藏 - 保留这个类以确保JavaScript功能正常 */
|
||||
.nav-group-toggle {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.pagefind-ui .pagefind-ui__search-input::placeholder {
|
||||
color: var(--color-gray-500) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pagefind-ui .pagefind-ui__search-input::placeholder {
|
||||
color: var(--color-gray-400) !important;
|
||||
}
|
||||
|
||||
.pagefind-ui .pagefind-ui__search-input:hover {
|
||||
border-color: var(--color-primary-300) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pagefind-ui .pagefind-ui__search-input:hover {
|
||||
border-color: var(--color-primary-600) !important;
|
||||
}
|
||||
|
||||
|
||||
.pagefind-ui *:focus-visible {
|
||||
outline:0 transparent !important;
|
||||
}
|
||||
|
||||
.pagefind-ui .pagefind-ui__search-input:focus {
|
||||
background-color: white !important;
|
||||
opacity: 1 !important;
|
||||
border-width: 2px !important;
|
||||
border-color: var(--color-primary-300) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pagefind-ui .pagefind-ui__search-input:focus {
|
||||
background-color: var(--color-gray-800) !important;
|
||||
border-color: var(--color-primary-600) !important;
|
||||
}
|
||||
|
||||
|
||||
/* 修复搜索图标位置 */
|
||||
.pagefind-ui .pagefind-ui__form::before {
|
||||
content: "";
|
||||
.nav-group-toggle.menu-up {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
left: 12px !important;
|
||||
z-index: 10;
|
||||
background-color: var(--color-gray-400) !important;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
|
||||
/* 搜索框清除按钮垂直居中 */
|
||||
.pagefind-ui .pagefind-ui__search-clear {
|
||||
transform: translateY(-20%);
|
||||
background: none !important;
|
||||
font-size: 0.8125rem !important;
|
||||
color: var(--color-gray-400) !important;
|
||||
/* 导航项的文字颜色过渡 */
|
||||
.nav-item, .nav-subitem, .nav-group-toggle {
|
||||
transition: color 0.3s ease, font-weight 0.15s ease;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 搜索结果抽屉 */
|
||||
.pagefind-ui .pagefind-ui__drawer{
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
z-index: 50;
|
||||
padding: 0.75rem;
|
||||
overflow-y: auto;
|
||||
max-height: 70vh;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-gray-200);
|
||||
background-color: var(--color-gray-50);
|
||||
width: 100%; /* 确保抽屉宽度不超过容器 */
|
||||
max-width: 100%; /* 限制最大宽度 */
|
||||
overflow-x: hidden; /* 防止横向滚动 */
|
||||
color:var(--color-gray-500);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pagefind-ui .pagefind-ui__drawer{
|
||||
border: 1px solid var(--color-gray-700);
|
||||
background-color: var(--color-gray-800);
|
||||
color:var(--color-gray-400);
|
||||
}
|
||||
|
||||
.pagefind-ui .pagefind-ui__results-area {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
/* 搜索结果消息文本 */
|
||||
.pagefind-ui .pagefind-ui__message {
|
||||
font-size: 0.55rem;
|
||||
word-wrap: break-word; /* 允许长消息文本换行 */
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 10px !important;
|
||||
}
|
||||
|
||||
/* 搜索结果内容 */
|
||||
.pagefind-ui .pagefind-ui__drawer ol,
|
||||
.pagefind-ui .pagefind-ui__drawer div,
|
||||
.pagefind-ui .pagefind-ui__drawer p,
|
||||
.pagefind-ui .pagefind-ui__drawer mark {
|
||||
word-wrap: break-word;
|
||||
min-width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.pagefind-ui .pagefind-ui__result {
|
||||
padding: 2px 0px !important;
|
||||
}
|
||||
|
||||
.pagefind-ui .pagefind-ui__result-link{
|
||||
color:var(--color-gray-800) !important;
|
||||
font-size: 1rem; /* 等于 14px */
|
||||
line-height: 1.5rem; /* 等于 20px */
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pagefind-ui .pagefind-ui__result-link{
|
||||
color:var(--color-gray-200) !important;
|
||||
}
|
||||
|
||||
.pagefind-ui .pagefind-ui__result-title{
|
||||
color:var(--color-gray-600) !important;
|
||||
font-size: 0.875rem; /* 等于 14px */
|
||||
line-height: 1.25rem; /* 等于 20px */
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pagefind-ui .pagefind-ui__result-title{
|
||||
color:var(--color-gray-400) !important;
|
||||
/* 页面加载时立即显示高亮,无需等待JavaScript */
|
||||
.nav-selector[data-has-active="true"] #nav-primary-highlight,
|
||||
.nav-selector[data-has-active="true"] #nav-secondary-highlight {
|
||||
opacity: 1;
|
||||
}
|
231
src/styles/mermaid-themes.css
Normal file
231
src/styles/mermaid-themes.css
Normal file
@ -0,0 +1,231 @@
|
||||
/* Mermaid图表主题样式
|
||||
* 支持亮色/暗色主题切换
|
||||
*/
|
||||
|
||||
/* 图表容器样式 */
|
||||
.mermaid-figure {
|
||||
margin: 2rem auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mermaid-svg-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-height: 80vh; /* 限制容器最大高度为视窗高度的80% */
|
||||
}
|
||||
|
||||
/* 错误信息样式 */
|
||||
.mermaid-error-message {
|
||||
color: #e53e3e;
|
||||
padding: 1rem;
|
||||
border: 1px dashed #e53e3e;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 基础样式 - 亮色主题 */
|
||||
.mermaid-svg {
|
||||
/* 全局图表容器样式 */
|
||||
max-width: 100%;
|
||||
height: auto !important; /* 强制高度为自动,覆盖内联样式 */
|
||||
max-height: 80vh; /* 限制最大高度为视窗高度的80% */
|
||||
width: auto; /* 允许宽度自适应内容 */
|
||||
overflow: visible;
|
||||
/* 移除背景色,使用透明背景 */
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 节点样式 */
|
||||
.mermaid-svg .node rect,
|
||||
.mermaid-svg .node circle,
|
||||
.mermaid-svg .node ellipse,
|
||||
.mermaid-svg .node polygon,
|
||||
.mermaid-svg .node path {
|
||||
fill: #f8fafc;
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
/* 基本填充和背景色 */
|
||||
.mermaid-svg .basic {
|
||||
fill: #f8fafc;
|
||||
stroke: #94a3b8;
|
||||
}
|
||||
|
||||
.mermaid-svg .labelBkg {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 集群样式 */
|
||||
.mermaid-svg .cluster rect {
|
||||
fill: #f1f5f9;
|
||||
stroke: #cbd5e1;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
/* 边标签样式 */
|
||||
.mermaid-svg .edgeLabel rect {
|
||||
fill: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 文本样式 */
|
||||
.mermaid-svg text {
|
||||
fill: #334155;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mermaid-svg .label {
|
||||
color: #334155;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.mermaid-svg .nodeLabel {
|
||||
color: #334155;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.mermaid-svg .cluster text {
|
||||
fill: #475569;
|
||||
}
|
||||
|
||||
/* 连线样式 */
|
||||
.mermaid-svg .edgePath .path {
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
.mermaid-svg .flowchart-link {
|
||||
stroke: #94a3b8;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.mermaid-svg marker {
|
||||
fill: #94a3b8;
|
||||
}
|
||||
|
||||
/* 箭头路径 */
|
||||
.mermaid-svg .arrowMarkerPath {
|
||||
fill: #94a3b8;
|
||||
stroke: #94a3b8;
|
||||
}
|
||||
|
||||
/* 特殊节点样式 */
|
||||
.mermaid-svg .node.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mermaid-svg .node.clickable:hover rect,
|
||||
.mermaid-svg .node.clickable:hover circle,
|
||||
.mermaid-svg .node.clickable:hover ellipse,
|
||||
.mermaid-svg .node.clickable:hover polygon {
|
||||
stroke-width: 2px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ======= 暗色主题样式 ======= */
|
||||
[data-theme="dark"] .mermaid-svg {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .node rect,
|
||||
[data-theme="dark"] .mermaid-svg .node circle,
|
||||
[data-theme="dark"] .mermaid-svg .node ellipse,
|
||||
[data-theme="dark"] .mermaid-svg .node polygon,
|
||||
[data-theme="dark"] .mermaid-svg .node path {
|
||||
fill: #1e293b;
|
||||
stroke: #475569;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .basic {
|
||||
fill: #1e293b;
|
||||
stroke: #475569;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .labelBkg {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .cluster rect {
|
||||
fill: #0f172a;
|
||||
stroke: #334155;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .edgeLabel rect {
|
||||
fill: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg text {
|
||||
fill: #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .label {
|
||||
color: #e2e8f0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .nodeLabel {
|
||||
color: #e2e8f0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .cluster text {
|
||||
fill: #cbd5e1;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .edgePath .path {
|
||||
stroke: #64748b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .flowchart-link {
|
||||
stroke: #64748b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg marker {
|
||||
fill: #64748b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .arrowMarkerPath {
|
||||
fill: #64748b;
|
||||
stroke: #64748b;
|
||||
}
|
||||
|
||||
/* 序列图特殊样式 */
|
||||
[data-theme="dark"] .mermaid-svg .actor {
|
||||
fill: #334155 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .messageLine0 {
|
||||
stroke: #94a3b8 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .messageLine1 {
|
||||
stroke: #94a3b8 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg #arrowhead path {
|
||||
fill: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* 甘特图特殊样式 */
|
||||
[data-theme="dark"] .mermaid-svg .section0 {
|
||||
fill: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .section1,
|
||||
[data-theme="dark"] .mermaid-svg .section2 {
|
||||
fill: rgba(255, 255, 255, 0.07) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-svg .task0,
|
||||
[data-theme="dark"] .mermaid-svg .task1,
|
||||
[data-theme="dark"] .mermaid-svg .task2,
|
||||
[data-theme="dark"] .mermaid-svg .task3 {
|
||||
fill: #1e3a8a !important;
|
||||
}
|
@ -66,40 +66,36 @@ table {
|
||||
background-color: #f5f7ff;
|
||||
}
|
||||
|
||||
/* 响应式表格 */
|
||||
@media (max-width: 640px) {
|
||||
table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
th, td {
|
||||
white-space: nowrap;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
/* 特定表格类型样式 */
|
||||
table.feature-comparison td,
|
||||
.feature-table td,
|
||||
.function-table td,
|
||||
.comparison-table td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 特定针对于内联样式的表格 */
|
||||
table[style] th,
|
||||
table[style] td {
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
table.feature-comparison td:first-child,
|
||||
.feature-table td:first-child,
|
||||
.function-table td:first-child,
|
||||
.comparison-table td:first-child {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 处理嵌套样式的情况 */
|
||||
.prose table {
|
||||
border-collapse: collapse !important;
|
||||
border-radius: 0.5rem !important;
|
||||
overflow: hidden !important;
|
||||
.feature-table th,
|
||||
.function-table th,
|
||||
.comparison-table th {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.prose table th,
|
||||
.prose table td {
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
.feature-table th:first-child,
|
||||
.function-table th:first-child,
|
||||
.comparison-table th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 勾号和叉号样式 - 通用方式 */
|
||||
.tick, .yes, .true {
|
||||
/* 勾号和叉号样式 */
|
||||
.tick, .yes, .true, .check-mark, .checkmark, .check, .✓ {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
color: #059669;
|
||||
@ -107,7 +103,7 @@ table {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.cross, .no, .false {
|
||||
.cross, .no, .false, .cross-mark, .crossmark, .cross-symbol, .✗ {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
color: #dc2626;
|
||||
@ -124,21 +120,6 @@ table {
|
||||
content: "✗";
|
||||
}
|
||||
|
||||
/* 直接使用✓或✗的样式 */
|
||||
.check-mark {
|
||||
color: #059669;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cross-mark {
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 状态标识符号样式 */
|
||||
td:has(> :is(svg[aria-label="yes"], .yes, .tick, .true)) {
|
||||
color: #047857;
|
||||
@ -148,21 +129,12 @@ table {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* 对表格中直接使用的✓和✗符号进行样式处理 */
|
||||
/* 单个单元格居中 */
|
||||
td:only-child {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 检查整个表格的样式 */
|
||||
table.feature-comparison td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table.feature-comparison td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 添加全局样式识别 */
|
||||
/* 全局状态颜色 */
|
||||
.text-success,
|
||||
.text-green,
|
||||
.success-mark {
|
||||
@ -177,415 +149,84 @@ table {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 直接针对表格中的✓和✗符号 - 移除不兼容的:contains选择器 */
|
||||
/* 改用更通用的选择器和类 */
|
||||
/* Prose 内表格样式 */
|
||||
.prose table {
|
||||
border-collapse: collapse !important;
|
||||
border-radius: 0.5rem !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.prose table th,
|
||||
.prose table td {
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* 表格内行内样式覆盖 */
|
||||
table[style] th,
|
||||
table[style] td {
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* 针对行内样式的表格 */
|
||||
table[style] td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 使用属性选择器 */
|
||||
td[align="center"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 为常见的功能对比表格添加专用样式 */
|
||||
.feature-table th,
|
||||
.function-table th,
|
||||
.comparison-table th {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-table th:first-child,
|
||||
.function-table th:first-child,
|
||||
.comparison-table th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feature-table td,
|
||||
.function-table td,
|
||||
.comparison-table td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-table td:first-child,
|
||||
.function-table td:first-child,
|
||||
.comparison-table td:first-child {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 直接样式化 ✓ 和 ✗ 字符 */
|
||||
.checkmark, .check, .✓ {
|
||||
color: #059669;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.crossmark, .cross-symbol, .✗ {
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
/* 暗色模式 - 只使用 [data-theme="dark"] 选择器 */
|
||||
[data-theme="dark"] {
|
||||
/* 响应式表格样式 */
|
||||
@media (max-width: 640px) {
|
||||
/* 表格容器 */
|
||||
table {
|
||||
box-shadow: 0 4px 12px -1px rgba(0, 0, 0, 0.3), 0 2px 6px -1px rgba(0, 0, 0, 0.2);
|
||||
border-color: #475569;
|
||||
background-color: #0f172a;
|
||||
display: table;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
table, th, td {
|
||||
border-color: #475569;
|
||||
/* 表格包装容器 - 注意:需要在HTML中添加div.table-wrapper包围表格 */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
max-width: 100%;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
table[style] th,
|
||||
table[style] td,
|
||||
.prose table th,
|
||||
.prose table td {
|
||||
border-color: #475569 !important;
|
||||
/* 确保标题和内容宽度一致 */
|
||||
table th,
|
||||
table td {
|
||||
min-width: 8rem;
|
||||
padding: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: #1a2e6a;
|
||||
border-bottom: 2px solid #4b6bff;
|
||||
}
|
||||
|
||||
th {
|
||||
border-bottom-color: #4b6bff;
|
||||
color: #adc2ff;
|
||||
background-color: transparent;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
th:not(:last-child) {
|
||||
border-right-color: #374151;
|
||||
}
|
||||
|
||||
td {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
td:not(:last-child) {
|
||||
border-right-color: #374151;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) {
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(odd) {
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: #272f45;
|
||||
box-shadow: inset 0 0 0 2px rgba(75, 107, 255, 0.3);
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 表格圆角样式在暗色模式下的处理 */
|
||||
table {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 勾号和叉号颜色调整 */
|
||||
.tick, .yes, .true, .check-mark {
|
||||
color: #10b981;
|
||||
text-shadow: 0 0 5px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.cross, .no, .false, .cross-mark {
|
||||
color: #ef4444;
|
||||
text-shadow: 0 0 5px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.text-success,
|
||||
.text-green,
|
||||
.success-mark {
|
||||
color: #10b981 !important;
|
||||
text-shadow: 0 0 5px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.text-danger,
|
||||
.text-red,
|
||||
.danger-mark {
|
||||
color: #ef4444 !important;
|
||||
text-shadow: 0 0 5px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.checkmark, .check, .✓ {
|
||||
color: #10b981;
|
||||
text-shadow: 0 0 5px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.crossmark, .cross-symbol, .✗ {
|
||||
color: #ef4444;
|
||||
text-shadow: 0 0 5px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}/* 表格基础样式 */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1.5em 0;
|
||||
font-size: 0.95rem;
|
||||
box-shadow: 0 4px 10px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* 表格边框样式 */
|
||||
table, th, td {
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* 表头样式 */
|
||||
thead {
|
||||
background-color: #f0f5ff;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 1rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
border-bottom: 2px solid #d6e0ff;
|
||||
color: #3451db;
|
||||
}
|
||||
|
||||
/* 表格单元格样式 */
|
||||
td {
|
||||
padding: 0.875rem 1rem;
|
||||
vertical-align: middle;
|
||||
color: #1e293b;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 内容对齐方式 */
|
||||
th[align="center"],
|
||||
td[align="center"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
th[align="right"],
|
||||
td[align="right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
th[align="left"],
|
||||
td[align="left"] {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 表格行交替颜色 */
|
||||
tbody tr:nth-child(even) {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(odd) {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
/* 鼠标悬停效果 */
|
||||
tbody tr:hover {
|
||||
background-color: #f5f7ff;
|
||||
}
|
||||
|
||||
/* 响应式表格 */
|
||||
@media (max-width: 640px) {
|
||||
table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
th, td {
|
||||
white-space: nowrap;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 特定针对于内联样式的表格 */
|
||||
table[style] th,
|
||||
table[style] td {
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* 处理嵌套样式的情况 */
|
||||
.prose table {
|
||||
border-collapse: collapse !important;
|
||||
border-radius: 0.5rem !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.prose table th,
|
||||
.prose table td {
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* 勾号和叉号样式 - 通用方式 */
|
||||
.tick, .yes, .true {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
color: #059669;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.cross, .no, .false {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
/* 添加内容 */
|
||||
.tick::before, .yes::before, .true::before {
|
||||
content: "✓";
|
||||
}
|
||||
|
||||
.cross::before, .no::before, .false::before {
|
||||
content: "✗";
|
||||
}
|
||||
|
||||
/* 直接使用✓或✗的样式 */
|
||||
.check-mark {
|
||||
color: #059669;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cross-mark {
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 状态标识符号样式 */
|
||||
td:has(> :is(svg[aria-label="yes"], .yes, .tick, .true)) {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
td:has(> :is(svg[aria-label="no"], .no, .cross, .false)) {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* 对表格中直接使用的✓和✗符号进行样式处理 */
|
||||
td:only-child {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 检查整个表格的样式 */
|
||||
table.feature-comparison td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table.feature-comparison td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 添加全局样式识别 */
|
||||
.text-success,
|
||||
.text-green,
|
||||
.success-mark {
|
||||
color: #059669 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text-danger,
|
||||
.text-red,
|
||||
.danger-mark {
|
||||
color: #dc2626 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 直接针对表格中的✓和✗符号 - 移除不兼容的:contains选择器 */
|
||||
/* 改用更通用的选择器和类 */
|
||||
|
||||
/* 针对行内样式的表格 */
|
||||
table[style] td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 使用属性选择器 */
|
||||
td[align="center"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 为常见的功能对比表格添加专用样式 */
|
||||
.feature-table th,
|
||||
.function-table th,
|
||||
.comparison-table th {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-table th:first-child,
|
||||
.function-table th:first-child,
|
||||
.comparison-table th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feature-table td,
|
||||
.function-table td,
|
||||
.comparison-table td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-table td:first-child,
|
||||
.function-table td:first-child,
|
||||
.comparison-table td:first-child {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 直接样式化 ✓ 和 ✗ 字符 */
|
||||
.checkmark, .check, .✓ {
|
||||
color: #059669;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.crossmark, .cross-symbol, .✗ {
|
||||
color: #dc2626;
|
||||
font-weight: bold;
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
/* 暗色模式 - 只使用 [data-theme="dark"] 选择器 */
|
||||
[data-theme="dark"] {
|
||||
table {
|
||||
/* 暗色模式 */
|
||||
[data-theme="dark"] table {
|
||||
box-shadow: 0 4px 12px -1px rgba(0, 0, 0, 0.3), 0 2px 6px -1px rgba(0, 0, 0, 0.2);
|
||||
border-color: #475569;
|
||||
background-color: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table, th, td {
|
||||
[data-theme="dark"] table,
|
||||
[data-theme="dark"] th,
|
||||
[data-theme="dark"] td {
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
table[style] th,
|
||||
table[style] td,
|
||||
.prose table th,
|
||||
.prose table td {
|
||||
[data-theme="dark"] table[style] th,
|
||||
[data-theme="dark"] table[style] td,
|
||||
[data-theme="dark"] .prose table th,
|
||||
[data-theme="dark"] .prose table td {
|
||||
border-color: #475569 !important;
|
||||
}
|
||||
|
||||
thead {
|
||||
[data-theme="dark"] thead {
|
||||
background-color: #1e293b;
|
||||
border-bottom: 2px solid #4b6bff;
|
||||
}
|
||||
|
||||
th {
|
||||
[data-theme="dark"] th {
|
||||
border-bottom-color: #4b6bff;
|
||||
color: #adc2ff;
|
||||
background-color: transparent;
|
||||
@ -593,73 +234,60 @@ td[align="center"] {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
th:not(:last-child) {
|
||||
[data-theme="dark"] th:not(:last-child) {
|
||||
border-right-color: #374151;
|
||||
}
|
||||
|
||||
td {
|
||||
[data-theme="dark"] td {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
td:not(:last-child) {
|
||||
[data-theme="dark"] td:not(:last-child) {
|
||||
border-right-color: #374151;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) {
|
||||
[data-theme="dark"] tbody tr:nth-child(even) {
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(odd) {
|
||||
[data-theme="dark"] tbody tr:nth-child(odd) {
|
||||
background-color: #111827;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
[data-theme="dark"] tbody tr:hover {
|
||||
background-color: #272f45;
|
||||
box-shadow: inset 0 0 0 2px rgba(75, 107, 255, 0.3);
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
[data-theme="dark"] tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 表格圆角样式在暗色模式下的处理 */
|
||||
table {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 勾号和叉号颜色调整 */
|
||||
.tick, .yes, .true, .check-mark {
|
||||
color: #10b981;
|
||||
text-shadow: 0 0 5px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.cross, .no, .false, .cross-mark {
|
||||
color: #ef4444;
|
||||
text-shadow: 0 0 5px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.text-success,
|
||||
.text-green,
|
||||
.success-mark {
|
||||
/* 暗色模式下的勾叉符号 */
|
||||
[data-theme="dark"] .tick,
|
||||
[data-theme="dark"] .yes,
|
||||
[data-theme="dark"] .true,
|
||||
[data-theme="dark"] .check-mark,
|
||||
[data-theme="dark"] .checkmark,
|
||||
[data-theme="dark"] .check,
|
||||
[data-theme="dark"] .✓,
|
||||
[data-theme="dark"] .text-success,
|
||||
[data-theme="dark"] .text-green,
|
||||
[data-theme="dark"] .success-mark {
|
||||
color: #10b981 !important;
|
||||
text-shadow: 0 0 5px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.text-danger,
|
||||
.text-red,
|
||||
.danger-mark {
|
||||
|
||||
[data-theme="dark"] .cross,
|
||||
[data-theme="dark"] .no,
|
||||
[data-theme="dark"] .false,
|
||||
[data-theme="dark"] .cross-mark,
|
||||
[data-theme="dark"] .crossmark,
|
||||
[data-theme="dark"] .cross-symbol,
|
||||
[data-theme="dark"] .✗,
|
||||
[data-theme="dark"] .text-danger,
|
||||
[data-theme="dark"] .text-red,
|
||||
[data-theme="dark"] .danger-mark {
|
||||
color: #ef4444 !important;
|
||||
text-shadow: 0 0 5px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.checkmark, .check, .✓ {
|
||||
color: #10b981;
|
||||
text-shadow: 0 0 5px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.crossmark, .cross-symbol, .✗ {
|
||||
color: #ef4444;
|
||||
text-shadow: 0 0 5px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,22 +1,15 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
1228
wasm/Cargo.lock
generated
Normal file
1228
wasm/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
wasm/Cargo.toml
Normal file
36
wasm/Cargo.toml
Normal file
@ -0,0 +1,36 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"article-filter",
|
||||
"article-indexer",
|
||||
"geo",
|
||||
"search",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
wasm-bindgen = "0.2.100"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
serde-wasm-bindgen = "0.6.5"
|
||||
geojson = "0.24.2"
|
||||
geo = "0.26"
|
||||
geo-types = "0.7.16"
|
||||
js-sys = "0.3.77"
|
||||
kdtree = "0.7"
|
||||
chrono = { version = "0.4.40", features = ["serde", "wasmbind"] }
|
||||
bincode = { version = "2.0.1", features = ["serde"] }
|
||||
flate2 = "1.1.1"
|
||||
wee_alloc = "0.4.5"
|
||||
console_error_panic_hook = "0.1.7"
|
||||
web-sys = { version = "0.3.77", features = ["console"] }
|
||||
regex = "1.11.1"
|
||||
clap = { version = "4.5.37", features = ["suggestions", "color"] }
|
||||
walkdir = "2.5.0"
|
||||
html5ever = "0.27.0"
|
||||
markup5ever_rcdom = "0.3.0"
|
||||
once_cell = "1.21.3"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = 3
|
||||
codegen-units = 1
|
22
wasm/article-filter/Cargo.toml
Normal file
22
wasm/article-filter/Cargo.toml
Normal file
@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "article-filter"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Filter for articles"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = { workspace = true }
|
||||
js-sys = { workspace = true }
|
||||
console_error_panic_hook = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde-wasm-bindgen = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
web-sys = { version = "0.3", features = ["console"] }
|
||||
utils-common = { path = "../utils-common" }
|
125
wasm/article-filter/src/builder.rs
Normal file
125
wasm/article-filter/src/builder.rs
Normal file
@ -0,0 +1,125 @@
|
||||
use utils_common::models::ArticleMetadata;
|
||||
use utils_common::compression::to_compressed;
|
||||
use crate::models::FilterIndex;
|
||||
use chrono::Datelike;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
/// 筛选索引构建器
|
||||
pub struct FilterBuilder {
|
||||
articles: Vec<ArticleMetadata>,
|
||||
}
|
||||
|
||||
impl FilterBuilder {
|
||||
/// 创建新的筛选索引构建器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
articles: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加文章到索引构建器
|
||||
pub fn add_article(&mut self, article: ArticleMetadata) {
|
||||
self.articles.push(article);
|
||||
}
|
||||
|
||||
/// 构建筛选索引
|
||||
pub fn build_filter_index(&self) -> Result<FilterIndex, String> {
|
||||
if self.articles.is_empty() {
|
||||
println!("错误: 无法构建索引,没有文章数据");
|
||||
return Err("无法构建索引: 没有文章数据".to_string());
|
||||
}
|
||||
|
||||
println!("开始构建筛选索引,文章数量: {}", self.articles.len());
|
||||
|
||||
// 创建索引数据结构
|
||||
let mut tag_index: HashMap<String, HashSet<usize>> = HashMap::new();
|
||||
let mut year_index: HashMap<i32, HashSet<usize>> = HashMap::new();
|
||||
let mut month_index: HashMap<String, HashSet<usize>> = HashMap::new();
|
||||
|
||||
// 填充索引
|
||||
for (i, article) in self.articles.iter().enumerate() {
|
||||
// 标签索引
|
||||
for tag in &article.tags {
|
||||
tag_index.entry(tag.clone()).or_insert_with(HashSet::new).insert(i);
|
||||
}
|
||||
|
||||
// 日期索引
|
||||
let date = article.date;
|
||||
let year = date.year();
|
||||
|
||||
// 按年索引
|
||||
year_index.entry(year).or_insert_with(HashSet::new).insert(i);
|
||||
|
||||
// 按年月索引 (格式:yyyy-mm)
|
||||
let month_key = format!("{}-{:02}", year, date.month());
|
||||
month_index.entry(month_key).or_insert_with(HashSet::new).insert(i);
|
||||
}
|
||||
|
||||
println!("索引构建完成,标签数量: {}, 年份数量: {}, 月份数量: {}",
|
||||
tag_index.len(), year_index.len(), month_index.len());
|
||||
|
||||
Ok(FilterIndex {
|
||||
articles: self.articles.clone(),
|
||||
tag_index,
|
||||
year_index,
|
||||
month_index,
|
||||
})
|
||||
}
|
||||
|
||||
/// 保存筛选索引到文件
|
||||
pub fn save_filter_index(&self, path: &str) -> Result<(), String> {
|
||||
println!("开始保存筛选索引到文件: {}", path);
|
||||
|
||||
// 构建过滤索引
|
||||
let filter_index = match self.build_filter_index() {
|
||||
Ok(index) => {
|
||||
println!("成功构建筛选索引,文章: {},标签: {}",
|
||||
index.articles.len(),
|
||||
index.tag_index.len());
|
||||
index
|
||||
},
|
||||
Err(e) => {
|
||||
println!("构建筛选索引失败: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存过滤索引
|
||||
let mut filter_file = match File::create(path) {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
println!("创建索引文件失败: {}", e);
|
||||
return Err(format!("无法创建筛选索引文件: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
// 使用版本号3.0
|
||||
let version = [3, 0];
|
||||
|
||||
let compressed_data = match to_compressed(&filter_index, version) {
|
||||
Ok(data) => {
|
||||
println!("数据压缩成功,压缩后大小: {} 字节", data.len());
|
||||
data
|
||||
},
|
||||
Err(e) => {
|
||||
println!("数据压缩失败: {}", e);
|
||||
return Err(format!("压缩筛选索引失败: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
// 写入文件
|
||||
match filter_file.write_all(&compressed_data) {
|
||||
Ok(_) => {
|
||||
println!("筛选索引已成功写入文件: {},大小: {} 字节", path, compressed_data.len());
|
||||
},
|
||||
Err(e) => {
|
||||
println!("写入筛选索引文件失败: {}", e);
|
||||
return Err(format!("无法写入筛选索引文件: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
375
wasm/article-filter/src/lib.rs
Normal file
375
wasm/article-filter/src/lib.rs
Normal file
@ -0,0 +1,375 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io;
|
||||
use once_cell::sync::OnceCell;
|
||||
use std::sync::Mutex;
|
||||
use serde_json;
|
||||
use web_sys::console;
|
||||
use utils_common::compression as utils;
|
||||
|
||||
// 导出模块
|
||||
pub mod models;
|
||||
pub mod builder;
|
||||
|
||||
// 全局索引存储
|
||||
static INDEX: OnceCell<Mutex<Option<ArticleIndex>>> = OnceCell::new();
|
||||
|
||||
/// 初始化函数 - 设置错误处理
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn start() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
/// 版本信息
|
||||
#[wasm_bindgen]
|
||||
pub fn version() -> String {
|
||||
"3.1.0".to_string() // 简化版本,移除了搜索功能
|
||||
}
|
||||
|
||||
//===== Models 部分 =====
|
||||
|
||||
/// 简化的文章元数据 - 只包含展示所需信息
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ArticleMetadata {
|
||||
/// 文章唯一标识符
|
||||
pub id: String,
|
||||
/// 文章标题
|
||||
pub title: String,
|
||||
/// 文章摘要
|
||||
pub summary: String,
|
||||
/// 发布日期
|
||||
pub date: DateTime<Utc>,
|
||||
/// 文章标签列表
|
||||
pub tags: Vec<String>,
|
||||
/// 文章URL路径
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// 文章索引 - 存储所有文章和索引数据
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ArticleIndex {
|
||||
/// 所有文章的元数据列表
|
||||
pub articles: Vec<ArticleMetadata>,
|
||||
/// 标签索引: 标签名 -> 文章ID列表
|
||||
pub tag_index: HashMap<String, Vec<usize>>,
|
||||
}
|
||||
|
||||
/// 筛选参数 - 客户端传递的筛选条件
|
||||
#[derive(Deserialize, Debug, Default)]
|
||||
pub struct FilterParams {
|
||||
/// 标签筛选条件 (可选)
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// 排序方式: "newest", "oldest", "title_asc", "title_desc" (可选)
|
||||
pub sort: Option<String>,
|
||||
/// 分页 - 当前页码 (可选, 默认为1)
|
||||
pub page: Option<usize>,
|
||||
/// 分页 - 每页条数 (可选, 默认为12)
|
||||
pub limit: Option<usize>,
|
||||
/// 日期筛选: "all" 或 "startDate,endDate" 格式的日期范围
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
/// 筛选结果 - 返回给客户端的筛选结果
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct FilterResult {
|
||||
/// 筛选后的文章列表
|
||||
pub articles: Vec<ArticleMetadata>,
|
||||
/// 筛选结果总数
|
||||
pub total: usize,
|
||||
/// 当前页码
|
||||
pub page: usize,
|
||||
/// 每页条数
|
||||
pub limit: usize,
|
||||
/// 总页数
|
||||
pub total_pages: usize,
|
||||
}
|
||||
|
||||
impl ArticleIndex {
|
||||
/// 从压缩的二进制数据恢复索引
|
||||
pub fn from_compressed(data: &[u8]) -> Result<Self, io::Error> {
|
||||
utils::from_compressed_with_max_version(data, 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// 文章过滤器 - 处理文章筛选逻辑
|
||||
pub struct ArticleFilter;
|
||||
|
||||
impl ArticleFilter {
|
||||
/// 加载索引数据
|
||||
pub fn load_index(data: &[u8]) -> Result<(), String> {
|
||||
// 将FilterIndex转换为ArticleIndex
|
||||
let filter_index = match utils::from_compressed_with_max_version::<crate::models::FilterIndex>(data, 3) {
|
||||
Ok(index) => {
|
||||
index
|
||||
},
|
||||
Err(e) => {
|
||||
console::log_1(&JsValue::from_str(&format!("索引解析失败: {}", e)));
|
||||
return Err(format!("解析索引失败: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
// 转换为ArticleIndex
|
||||
let article_index = Self::convert_filter_to_article_index(filter_index);
|
||||
|
||||
// 存储到全局变量
|
||||
INDEX.get_or_init(|| Mutex::new(Some(article_index)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 将FilterIndex转换为ArticleIndex
|
||||
fn convert_filter_to_article_index(filter_index: crate::models::FilterIndex) -> ArticleIndex {
|
||||
// 转换文章元数据
|
||||
let articles: Vec<ArticleMetadata> = filter_index.articles
|
||||
.into_iter()
|
||||
.map(|article| {
|
||||
// 只保留需要的字段
|
||||
ArticleMetadata {
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
summary: article.summary,
|
||||
date: article.date,
|
||||
tags: article.tags,
|
||||
url: article.url,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 转换标签索引
|
||||
let mut tag_index = HashMap::new();
|
||||
for (tag, article_ids) in filter_index.tag_index {
|
||||
tag_index.insert(tag, article_ids.into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
ArticleIndex {
|
||||
articles,
|
||||
tag_index,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有标签
|
||||
pub fn get_all_tags() -> Result<Vec<String>, String> {
|
||||
// 获取索引
|
||||
let index_mutex = INDEX.get().ok_or("索引未初始化")?;
|
||||
let index_guard = index_mutex.lock().map_err(|_| "获取索引锁失败")?;
|
||||
let index = index_guard.as_ref().ok_or("索引为空")?;
|
||||
|
||||
// 提取所有标签
|
||||
let tags = index.tag_index.keys().cloned().collect();
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// 筛选文章
|
||||
pub fn filter_articles(params: &FilterParams) -> Result<FilterResult, String> {
|
||||
// 获取索引
|
||||
let index_mutex = INDEX.get().ok_or("索引未初始化")?;
|
||||
let index_guard = index_mutex.lock().map_err(|_| "获取索引锁失败")?;
|
||||
let index = index_guard.as_ref().ok_or("索引为空")?;
|
||||
|
||||
// 筛选候选文章
|
||||
let candidate_ids = Self::apply_filters(index, params)?;
|
||||
|
||||
// 从ID获取文章元数据
|
||||
let mut filtered_articles = candidate_ids
|
||||
.into_iter()
|
||||
.filter_map(|id| index.articles.get(id).cloned())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// 排序
|
||||
Self::apply_sorting(&mut filtered_articles, params);
|
||||
|
||||
// 分页
|
||||
let page = params.page.unwrap_or(1).max(1);
|
||||
let limit = params.limit.unwrap_or(12).max(1);
|
||||
let total = filtered_articles.len();
|
||||
let total_pages = (total + limit - 1) / limit.max(1);
|
||||
let page = page.min(total_pages.max(1));
|
||||
|
||||
let start = (page - 1) * limit;
|
||||
let end = (start + limit).min(total);
|
||||
|
||||
let paged_articles = if start < filtered_articles.len() {
|
||||
filtered_articles[start..end].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// 构建结果
|
||||
Ok(FilterResult {
|
||||
articles: paged_articles,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
total_pages,
|
||||
})
|
||||
}
|
||||
|
||||
// 应用筛选条件
|
||||
fn apply_filters(index: &ArticleIndex, params: &FilterParams) -> Result<Vec<usize>, String> {
|
||||
// 初始化候选文章 ID 集合,默认包含所有文章
|
||||
let mut candidate_ids: HashSet<usize> = (0..index.articles.len()).collect();
|
||||
|
||||
// 标签筛选
|
||||
if let Some(tags) = ¶ms.tags {
|
||||
if !tags.is_empty() {
|
||||
let tag_candidates = Self::filter_by_tags(index, tags);
|
||||
|
||||
// 保留同时存在于两个集合中的元素
|
||||
candidate_ids.retain(|id| tag_candidates.contains(id));
|
||||
}
|
||||
}
|
||||
|
||||
// 日期筛选
|
||||
if let Some(date_param) = ¶ms.date {
|
||||
if date_param != "all" {
|
||||
// 解析日期范围(格式: "startDate,endDate")
|
||||
let date_parts: Vec<&str> = date_param.split(',').collect();
|
||||
let start_date_str = date_parts.get(0).map(|s| *s).unwrap_or("");
|
||||
let end_date_str = date_parts.get(1).map(|s| *s).unwrap_or("");
|
||||
|
||||
let has_start_date = !start_date_str.is_empty();
|
||||
let has_end_date = !end_date_str.is_empty();
|
||||
|
||||
if has_start_date && has_end_date {
|
||||
// 两个日期都存在的情况
|
||||
// 尝试解析日期
|
||||
let start_date_fmt = format!("{}T00:00:00Z", start_date_str);
|
||||
let end_date_fmt = format!("{}T23:59:59Z", end_date_str);
|
||||
|
||||
match (
|
||||
chrono::DateTime::parse_from_rfc3339(&start_date_fmt),
|
||||
chrono::DateTime::parse_from_rfc3339(&end_date_fmt)
|
||||
) {
|
||||
(Ok(start), Ok(end)) => {
|
||||
let start_utc = start.with_timezone(&chrono::Utc);
|
||||
let end_utc = end.with_timezone(&chrono::Utc);
|
||||
|
||||
candidate_ids.retain(|&id| {
|
||||
if let Some(article) = index.articles.get(id) {
|
||||
article.date >= start_utc && article.date <= end_utc
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
} else if has_start_date {
|
||||
// 只有开始日期的情况
|
||||
let start_date_fmt = format!("{}T00:00:00Z", start_date_str);
|
||||
|
||||
if let Ok(start) = chrono::DateTime::parse_from_rfc3339(&start_date_fmt) {
|
||||
let start_utc = start.with_timezone(&chrono::Utc);
|
||||
|
||||
candidate_ids.retain(|&id| {
|
||||
if let Some(article) = index.articles.get(id) {
|
||||
article.date >= start_utc
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if has_end_date {
|
||||
// 只有结束日期的情况
|
||||
let end_date_fmt = format!("{}T23:59:59Z", end_date_str);
|
||||
|
||||
if let Ok(end) = chrono::DateTime::parse_from_rfc3339(&end_date_fmt) {
|
||||
let end_utc = end.with_timezone(&chrono::Utc);
|
||||
|
||||
candidate_ids.retain(|&id| {
|
||||
if let Some(article) = index.articles.get(id) {
|
||||
article.date <= end_utc
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(candidate_ids.into_iter().collect())
|
||||
}
|
||||
|
||||
// 按标签筛选
|
||||
fn filter_by_tags(index: &ArticleIndex, tags: &[String]) -> HashSet<usize> {
|
||||
let mut result = HashSet::new();
|
||||
|
||||
for tag in tags {
|
||||
if let Some(article_ids) = index.tag_index.get(tag) {
|
||||
for &id in article_ids {
|
||||
result.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// 应用排序
|
||||
fn apply_sorting(articles: &mut [ArticleMetadata], params: &FilterParams) {
|
||||
match params.sort.as_deref() {
|
||||
Some("oldest") => {
|
||||
articles.sort_by(|a, b| a.date.cmp(&b.date));
|
||||
}
|
||||
Some("title_asc") => {
|
||||
articles.sort_by(|a, b| a.title.cmp(&b.title));
|
||||
}
|
||||
Some("title_desc") => {
|
||||
articles.sort_by(|a, b| b.title.cmp(&a.title));
|
||||
}
|
||||
_ => {
|
||||
// 默认按最新排序
|
||||
articles.sort_by(|a, b| b.date.cmp(&a.date));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 文章过滤器JS接口 - 提供给JavaScript使用的筛选API
|
||||
#[wasm_bindgen]
|
||||
pub struct ArticleFilterJS;
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl ArticleFilterJS {
|
||||
/// 初始化过滤器并加载索引
|
||||
#[wasm_bindgen]
|
||||
pub fn init(index_data: &[u8]) -> Result<(), JsValue> {
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
let result = ArticleFilter::load_index(index_data)
|
||||
.map_err(|e| {
|
||||
console::log_1(&JsValue::from_str(&format!("初始化过滤器失败: {}", e)));
|
||||
JsValue::from_str(&e)
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 获取所有标签
|
||||
#[wasm_bindgen]
|
||||
pub fn get_all_tags() -> Result<JsValue, JsValue> {
|
||||
let tags = ArticleFilter::get_all_tags()
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
serde_wasm_bindgen::to_value(&tags)
|
||||
.map_err(|e| JsValue::from_str(&format!("序列化标签失败: {}", e)))
|
||||
}
|
||||
|
||||
/// 筛选文章
|
||||
#[wasm_bindgen]
|
||||
pub fn filter_articles(params_json: &str) -> Result<JsValue, JsValue> {
|
||||
// 解析参数
|
||||
let params: FilterParams = serde_json::from_str(params_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("解析参数失败: {}", e)))?;
|
||||
|
||||
// 筛选文章
|
||||
let result = ArticleFilter::filter_articles(¶ms)
|
||||
.map_err(|e| JsValue::from_str(&e))?;
|
||||
|
||||
// 序列化结果
|
||||
serde_wasm_bindgen::to_value(&result)
|
||||
.map_err(|e| JsValue::from_str(&format!("序列化结果失败: {}", e)))
|
||||
}
|
||||
}
|
34
wasm/article-filter/src/models.rs
Normal file
34
wasm/article-filter/src/models.rs
Normal file
@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use utils_common::models::ArticleMetadata;
|
||||
|
||||
/// 筛选索引 - 存储标签和日期索引
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct FilterIndex {
|
||||
/// 所有文章的元数据列表
|
||||
pub articles: Vec<ArticleMetadata>,
|
||||
/// 标签到文章ID列表的映射
|
||||
pub tag_index: HashMap<String, HashSet<usize>>,
|
||||
/// 年份到文章ID列表的映射
|
||||
pub year_index: HashMap<i32, HashSet<usize>>,
|
||||
/// 月份到文章ID列表的映射(格式:yyyy-mm)
|
||||
pub month_index: HashMap<String, HashSet<usize>>,
|
||||
}
|
||||
|
||||
/// 筛选规则 - 定义筛选条件
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct FilterRules {
|
||||
/// 需要包含的标签列表
|
||||
pub tags: Vec<String>,
|
||||
/// 排序方式: date_desc, date_asc, title_asc, title_desc
|
||||
pub sort_by: String,
|
||||
}
|
||||
|
||||
impl Default for FilterRules {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tags: Vec::new(),
|
||||
sort_by: "date_desc".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
19
wasm/article-indexer/Cargo.toml
Normal file
19
wasm/article-indexer/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "article-indexer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Tool for building article indices"
|
||||
|
||||
[[bin]]
|
||||
name = "article-indexer-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true, features = ["suggestions", "color"] }
|
||||
walkdir = { workspace = true }
|
||||
html5ever = { workspace = true }
|
||||
markup5ever_rcdom = { workspace = true }
|
||||
search-wasm = { path = "../search" }
|
||||
utils-common = { path = "../utils-common" }
|
||||
article-filter = { path = "../article-filter" }
|
880
wasm/article-indexer/src/main.rs
Normal file
880
wasm/article-indexer/src/main.rs
Normal file
@ -0,0 +1,880 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use chrono::Utc;
|
||||
use clap::{Command, Arg, ArgAction};
|
||||
use html5ever::parse_document;
|
||||
use html5ever::tendril::TendrilSink;
|
||||
use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use utils_common::{ArticleMetadata, Heading};
|
||||
|
||||
// 导入筛选和搜索模块
|
||||
use article_filter::builder::FilterBuilder;
|
||||
use search_wasm::builder::SearchBuilder;
|
||||
|
||||
// 主函数
|
||||
fn main() {
|
||||
// 设置命令行参数
|
||||
let matches = Command::new("文章索引生成器")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.author("New Echoes")
|
||||
.about("生成文章索引用于搜索和筛选")
|
||||
.arg(Arg::new("source")
|
||||
.short('s')
|
||||
.long("source")
|
||||
.value_name("SOURCE_DIR")
|
||||
.help("文章源目录路径")
|
||||
.required(true))
|
||||
.arg(Arg::new("output")
|
||||
.short('o')
|
||||
.long("output")
|
||||
.value_name("OUTPUT_DIR")
|
||||
.help("索引输出目录路径")
|
||||
.required(true))
|
||||
.arg(Arg::new("verbose")
|
||||
.short('v')
|
||||
.long("verbose")
|
||||
.help("显示详细信息")
|
||||
.action(ArgAction::SetTrue))
|
||||
.arg(Arg::new("index_all")
|
||||
.short('a')
|
||||
.long("all")
|
||||
.help("索引所有页面,包括非文章页面")
|
||||
.action(ArgAction::SetTrue))
|
||||
.get_matches();
|
||||
|
||||
// 获取参数值
|
||||
let source_dir = matches.get_one::<String>("source").unwrap();
|
||||
let output_dir = matches.get_one::<String>("output").unwrap();
|
||||
let verbose = matches.get_flag("verbose");
|
||||
let index_all = matches.get_flag("index_all");
|
||||
|
||||
// 检查目录
|
||||
let source_path = std::path::Path::new(source_dir);
|
||||
if !source_path.exists() || !source_path.is_dir() {
|
||||
eprintln!("错误: 源目录不存在或不是有效目录 '{}'", source_dir);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// 创建输出目录
|
||||
let output_path = std::path::Path::new(output_dir);
|
||||
if !output_path.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(output_path) {
|
||||
eprintln!("错误: 无法创建输出目录 '{}': {}", output_dir, e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
println!("开始生成索引...");
|
||||
println!("源目录: {}", source_dir);
|
||||
println!("输出目录: {}", output_dir);
|
||||
|
||||
// 生成索引
|
||||
match generate_index(source_dir, output_dir, verbose, index_all) {
|
||||
Ok(_) => println!("索引生成成功!"),
|
||||
Err(e) => {
|
||||
eprintln!("错误: 索引生成失败: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成索引的主函数
|
||||
fn generate_index(
|
||||
source_dir: &str,
|
||||
output_dir: &str,
|
||||
verbose: bool,
|
||||
index_all: bool
|
||||
) -> Result<(), String> {
|
||||
// 记录开始时间
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// 扫描HTML文件
|
||||
println!("扫描HTML文件...");
|
||||
let (articles, skipped_count) = scan_html_files(source_dir, verbose, index_all)?;
|
||||
|
||||
let article_count = articles.len();
|
||||
println!("扫描完成。找到 {} 篇有效文章,跳过 {} 个文件。", article_count, skipped_count);
|
||||
|
||||
if article_count == 0 {
|
||||
return Err("没有找到有效文章".to_string());
|
||||
}
|
||||
|
||||
// 创建筛选索引构建器
|
||||
let mut filter_builder = FilterBuilder::new();
|
||||
|
||||
// 创建搜索索引构建器
|
||||
let mut search_builder = SearchBuilder::new();
|
||||
|
||||
// 添加文章到构建器
|
||||
for article in articles {
|
||||
filter_builder.add_article(article.clone());
|
||||
search_builder.add_article(article);
|
||||
}
|
||||
|
||||
// 构建输出路径
|
||||
let filter_index_path = format!("{}/filter_index.bin", output_dir);
|
||||
let search_index_path = format!("{}/search_index.bin", output_dir);
|
||||
|
||||
// 保存索引
|
||||
println!("正在生成和保存索引...");
|
||||
filter_builder.save_filter_index(&filter_index_path)?;
|
||||
search_builder.save_search_index(&search_index_path)?;
|
||||
|
||||
// 计算耗时
|
||||
let elapsed = start_time.elapsed();
|
||||
println!("索引生成完成!耗时: {:.2}秒", elapsed.as_secs_f32());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 扫描HTML文件并提取文章数据
|
||||
fn scan_html_files(
|
||||
dir_path: &str,
|
||||
verbose: bool,
|
||||
index_all: bool
|
||||
) -> Result<(Vec<ArticleMetadata>, usize), String> {
|
||||
let mut articles = Vec::new();
|
||||
let dir_path = Path::new(dir_path);
|
||||
let mut processed_files = 0;
|
||||
|
||||
// 调试计数器
|
||||
let mut total_files = 0;
|
||||
let mut article_files = 0;
|
||||
|
||||
// 递归遍历目录
|
||||
for entry in WalkDir::new(dir_path) {
|
||||
let entry = entry.map_err(|e| format!("遍历目录时出错: {}", e))?;
|
||||
|
||||
// 只处理HTML文件
|
||||
if !entry.file_type().is_file() || !entry.path().extension().map_or(false, |ext| ext == "html") {
|
||||
continue;
|
||||
}
|
||||
|
||||
total_files += 1;
|
||||
processed_files += 1;
|
||||
|
||||
// 解析HTML文件
|
||||
match extract_article_from_html(entry.path(), dir_path, index_all, verbose) {
|
||||
Ok(Some(article)) => {
|
||||
articles.push(article);
|
||||
article_files += 1;
|
||||
}
|
||||
Ok(None) => {
|
||||
// 跳过不符合条件的文件
|
||||
}
|
||||
Err(err) => {
|
||||
if verbose {
|
||||
eprintln!("解析文件时出错 {}: {}", entry.path().display(), err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打印统计信息
|
||||
if verbose {
|
||||
println!("总HTML文件数: {}, 识别为文章的文件数: {}", total_files, article_files);
|
||||
}
|
||||
|
||||
Ok((articles, processed_files))
|
||||
}
|
||||
|
||||
// 从HTML文件中提取文章数据
|
||||
fn extract_article_from_html(file_path: &Path, base_dir: &Path, index_all: bool, verbose: bool) -> Result<Option<ArticleMetadata>, String> {
|
||||
// 读取文件内容
|
||||
let html = fs::read_to_string(file_path)
|
||||
.map_err(|e| format!("无法读取文件 {}: {}", file_path.display(), e))?;
|
||||
|
||||
// 检查路径,跳过已知的非内容文件
|
||||
let file_path_str = file_path.to_string_lossy().to_lowercase();
|
||||
let is_system_file = file_path_str.contains("/404.html") ||
|
||||
file_path_str.contains("\\404.html") ||
|
||||
file_path_str.contains("/search/") ||
|
||||
file_path_str.contains("\\search\\") ||
|
||||
file_path_str.contains("/robots.txt") ||
|
||||
file_path_str.contains("\\robots.txt") ||
|
||||
file_path_str.contains("/sitemap.xml") ||
|
||||
file_path_str.contains("\\sitemap.xml");
|
||||
|
||||
if is_system_file {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 解析HTML
|
||||
let dom = parse_document(RcDom::default(), Default::default())
|
||||
.from_utf8()
|
||||
.read_from(&mut html.as_bytes())
|
||||
.map_err(|e| format!("解析HTML时出错: {}", e))?;
|
||||
// 提取元数据
|
||||
let meta_tags = extract_meta_tags(&dom.document);
|
||||
|
||||
// 获取og:type标签值,这是页面类型的权威来源
|
||||
let og_type = meta_tags.get("og:type").map(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
// 严格确定页面类型,不做猜测
|
||||
let page_type = match og_type {
|
||||
"article" => "article",
|
||||
"page" => "page",
|
||||
"directory" => "directory",
|
||||
_ => {
|
||||
// 如果没有有效的og:type,尝试通过其他方式判断
|
||||
if html.contains("property=\"og:type\"") && html.contains("content=\"article\"") {
|
||||
"article"
|
||||
} else if html.contains("property=\"og:type\"") && html.contains("content=\"page\"") {
|
||||
"page"
|
||||
} else if html.contains("property=\"og:type\"") && html.contains("content=\"directory\"") {
|
||||
"directory"
|
||||
} else {
|
||||
// 默认未知类型
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 根据--all参数和页面类型决定是否处理
|
||||
let should_process = if index_all {
|
||||
// --all模式下,处理article和page类型
|
||||
page_type == "article" || page_type == "page"
|
||||
} else {
|
||||
// 非--all模式下,仅处理article类型
|
||||
page_type == "article"
|
||||
};
|
||||
|
||||
// 如果不符合处理条件,跳过
|
||||
if !should_process {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 提取标题
|
||||
let title = extract_title(&dom.document);
|
||||
if title.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 计算相对路径作为文章ID
|
||||
let relative_path = file_path.strip_prefix(base_dir)
|
||||
.map_err(|_| format!("计算相对路径失败"))?;
|
||||
|
||||
let id = relative_path.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.trim_end_matches("index")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
|
||||
// 提取正文内容
|
||||
let content = extract_content(&dom.document);
|
||||
|
||||
// 内容太少的可能不是有效内容页面
|
||||
if content.trim().len() < 30 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if verbose {
|
||||
println!("处理: {}", file_path.display());
|
||||
}
|
||||
|
||||
// 提取文章中的标题结构
|
||||
let headings = extract_headings(&dom.document, &content);
|
||||
|
||||
// 构建URL
|
||||
let url = format!("/{}", id);
|
||||
|
||||
// 提取摘要
|
||||
let summary = if !content.is_empty() {
|
||||
let mut summary = content.chars().take(200).collect::<String>();
|
||||
summary.push_str("...");
|
||||
summary
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// 提取标签 - 优先使用article:tag标准格式
|
||||
let tags = {
|
||||
let mut tags = Vec::new();
|
||||
|
||||
// 从meta标签中提取标签信息
|
||||
for (key, value) in meta_tags.iter() {
|
||||
if key == "article:tag" || key == "keywords" {
|
||||
let tag_values = value.split(',').map(|s| s.trim().to_string());
|
||||
tags.extend(tag_values);
|
||||
}
|
||||
}
|
||||
|
||||
// 去除空标签和重复标签
|
||||
tags.retain(|tag| !tag.trim().is_empty());
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
|
||||
tags
|
||||
};
|
||||
|
||||
// 日期提取 - 优先使用article:published_time标准格式
|
||||
let date = meta_tags.get("article:published_time")
|
||||
.and_then(|date_str| {
|
||||
chrono::DateTime::parse_from_rfc3339(date_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
Utc::now()
|
||||
});
|
||||
|
||||
// 创建文章元数据,保留原始页面类型信息,并添加标题结构
|
||||
let article = ArticleMetadata {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
date,
|
||||
tags,
|
||||
url,
|
||||
content,
|
||||
page_type: page_type.to_string(),
|
||||
headings,
|
||||
};
|
||||
|
||||
Ok(Some(article))
|
||||
}
|
||||
|
||||
// 从DOM中提取标题
|
||||
fn extract_title(handle: &Handle) -> String {
|
||||
// 首先尝试从<title>标签获取
|
||||
if let Some(title) = extract_title_tag(handle) {
|
||||
return title;
|
||||
}
|
||||
|
||||
// 然后尝试从<h1>标签获取
|
||||
if let Some(h1) = extract_h1_tag(handle) {
|
||||
return h1;
|
||||
}
|
||||
|
||||
// 最后返回空字符串
|
||||
String::new()
|
||||
}
|
||||
|
||||
// 从DOM中提取<title>标签内容
|
||||
fn extract_title_tag(handle: &Handle) -> Option<String> {
|
||||
match handle.data {
|
||||
NodeData::Document => {
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(title) = extract_title_tag(child) {
|
||||
return Some(title);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
NodeData::Element { ref name, .. } => {
|
||||
if name.local.as_ref() == "title" {
|
||||
// 获取文本内容
|
||||
let mut text = String::new();
|
||||
extract_text_from_node(handle, &mut text);
|
||||
return Some(text.trim().to_string());
|
||||
} else {
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(title) = extract_title_tag(child) {
|
||||
return Some(title);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(title) = extract_title_tag(child) {
|
||||
return Some(title);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从DOM中提取<h1>标签内容
|
||||
fn extract_h1_tag(handle: &Handle) -> Option<String> {
|
||||
match handle.data {
|
||||
NodeData::Element { ref name, .. } => {
|
||||
if name.local.as_ref() == "h1" {
|
||||
// 获取文本内容
|
||||
let mut text = String::new();
|
||||
extract_text_from_node(handle, &mut text);
|
||||
return Some(text.trim().to_string());
|
||||
} else {
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(h1) = extract_h1_tag(child) {
|
||||
return Some(h1);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(h1) = extract_h1_tag(child) {
|
||||
return Some(h1);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从DOM中提取元数据标签
|
||||
fn extract_meta_tags(handle: &Handle) -> std::collections::HashMap<String, String> {
|
||||
let mut meta_tags = std::collections::HashMap::new();
|
||||
extract_meta_tags_internal(handle, &mut meta_tags);
|
||||
meta_tags
|
||||
}
|
||||
|
||||
// 递归辅助函数,用于提取元数据标签
|
||||
fn extract_meta_tags_internal(handle: &Handle, meta_tags: &mut std::collections::HashMap<String, String>) {
|
||||
match handle.data {
|
||||
NodeData::Element { ref name, ref attrs, .. } => {
|
||||
let tag_name = name.local.to_string();
|
||||
|
||||
if tag_name == "meta" {
|
||||
let attrs = attrs.borrow();
|
||||
|
||||
// 高度优先处理og:type属性,确保它被正确识别
|
||||
let has_og_type = attrs.iter().any(|attr|
|
||||
(attr.name.local.to_string() == "property" && attr.value.contains("og:type")) ||
|
||||
(attr.name.local.to_string() == "name" && attr.value.contains("og:type"))
|
||||
);
|
||||
|
||||
if has_og_type {
|
||||
// 直接找到content属性
|
||||
if let Some(content_attr) = attrs.iter().find(|attr| attr.name.local.to_string() == "content") {
|
||||
meta_tags.insert("og:type".to_string(), content_attr.value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 处理常规meta标签
|
||||
if let (Some(name_attr), Some(content_attr)) = (
|
||||
attrs.iter().find(|attr| attr.name.local.to_string() == "name"),
|
||||
attrs.iter().find(|attr| attr.name.local.to_string() == "content")
|
||||
) {
|
||||
meta_tags.insert(name_attr.value.to_string(), content_attr.value.to_string());
|
||||
}
|
||||
// 处理Open Graph属性(property属性)
|
||||
else if let (Some(property_attr), Some(content_attr)) = (
|
||||
attrs.iter().find(|attr| attr.name.local.to_string() == "property"),
|
||||
attrs.iter().find(|attr| attr.name.local.to_string() == "content")
|
||||
) {
|
||||
// 将完整的属性名保存到meta_tags
|
||||
let property = property_attr.value.to_string();
|
||||
meta_tags.insert(property.clone(), content_attr.value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
for child in handle.children.borrow().iter() {
|
||||
extract_meta_tags_internal(child, meta_tags);
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
// 递归处理子节点
|
||||
for child in handle.children.borrow().iter() {
|
||||
extract_meta_tags_internal(child, meta_tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从DOM中提取正文内容
|
||||
fn extract_content(handle: &Handle) -> String {
|
||||
let mut content = String::new();
|
||||
|
||||
// 根据语义化标签顺序查找内容
|
||||
if let Some(article_element) = find_article_element(handle) {
|
||||
extract_text_from_node_filtered(&article_element, &mut content);
|
||||
} else if let Some(main_content) = find_main_content(handle) {
|
||||
extract_text_from_node_filtered(&main_content, &mut content);
|
||||
} else if let Some(body) = find_body(handle) {
|
||||
extract_text_from_node_filtered(&body, &mut content);
|
||||
}
|
||||
|
||||
// 内联处理空格和换行
|
||||
let mut result = content.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
|
||||
// 去除多余空格
|
||||
while result.contains(" ") {
|
||||
result = result.replace(" ", " ");
|
||||
}
|
||||
|
||||
// 去除多余换行
|
||||
while result.contains("\n\n") {
|
||||
result = result.replace("\n\n", "\n");
|
||||
}
|
||||
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
// 查找文章元素 - 使用语义化标签
|
||||
fn find_article_element(handle: &Handle) -> Option<Handle> {
|
||||
match handle.data {
|
||||
NodeData::Element { ref name, .. } => {
|
||||
// 直接查找article标签,这是语义化的文章内容区
|
||||
if name.local.as_ref() == "article" {
|
||||
return Some(handle.clone());
|
||||
}
|
||||
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(article) = find_article_element(child) {
|
||||
return Some(article);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(article) = find_article_element(child) {
|
||||
return Some(article);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找主要内容区域
|
||||
fn find_main_content(handle: &Handle) -> Option<Handle> {
|
||||
match handle.data {
|
||||
NodeData::Element { ref name, .. } => {
|
||||
// 查找语义化标签
|
||||
if name.local.as_ref() == "main" {
|
||||
return Some(handle.clone());
|
||||
}
|
||||
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(main) = find_main_content(child) {
|
||||
return Some(main);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(main) = find_main_content(child) {
|
||||
return Some(main);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找body元素
|
||||
fn find_body(handle: &Handle) -> Option<Handle> {
|
||||
match handle.data {
|
||||
NodeData::Element { ref name, .. } => {
|
||||
if name.local.as_ref() == "body" {
|
||||
return Some(handle.clone());
|
||||
}
|
||||
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(body) = find_body(child) {
|
||||
return Some(body);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
// 递归查找
|
||||
for child in handle.children.borrow().iter() {
|
||||
if let Some(body) = find_body(child) {
|
||||
return Some(body);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从节点提取文本,过滤掉非内容标签
|
||||
fn extract_text_from_node_filtered(handle: &Handle, text: &mut String) {
|
||||
match handle.data {
|
||||
NodeData::Element { ref name, ref attrs, .. } => {
|
||||
let tag_name = name.local.to_string();
|
||||
|
||||
// 跳过aside标签
|
||||
if tag_name == "aside" {
|
||||
return;
|
||||
}
|
||||
|
||||
// 对于section标签,检查是否为目录区
|
||||
if tag_name == "section" {
|
||||
let attrs = attrs.borrow();
|
||||
let is_toc_section = attrs.iter().any(|attr| {
|
||||
(attr.name.local.to_string() == "id" &&
|
||||
(attr.value.contains("toc") || attr.value.contains("directory"))) ||
|
||||
(attr.name.local.to_string() == "class" &&
|
||||
(attr.value.contains("toc") || attr.value.contains("directory")))
|
||||
});
|
||||
|
||||
if is_toc_section {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 跳过交互元素和导航元素
|
||||
let non_content_tags = [
|
||||
// 脚本和样式
|
||||
"script", "style",
|
||||
// 元数据和链接
|
||||
"head", "meta", "link",
|
||||
// 语义化页面结构中的非内容区
|
||||
"header", "footer", "nav", "aside",
|
||||
// 其他交互元素
|
||||
"noscript", "iframe", "svg", "path",
|
||||
"button", "input", "form", "select", "option", "textarea",
|
||||
"template", "dialog", "canvas"
|
||||
];
|
||||
|
||||
if non_content_tags.contains(&tag_name.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是sr-only元素(屏幕阅读器专用)
|
||||
let attrs = attrs.borrow();
|
||||
let is_sr_only = attrs.iter().any(|attr| {
|
||||
attr.name.local.to_string() == "class" &&
|
||||
attr.value.contains("sr-only")
|
||||
});
|
||||
|
||||
if is_sr_only {
|
||||
return;
|
||||
}
|
||||
|
||||
// 跳过其他可能的非内容区域(使用通用检测)
|
||||
for attr in attrs.iter() {
|
||||
if attr.name.local.to_string() == "class" || attr.name.local.to_string() == "id" {
|
||||
let value = attr.value.to_string().to_lowercase();
|
||||
if value.contains("nav") ||
|
||||
value.contains("menu") ||
|
||||
value.contains("sidebar") ||
|
||||
value.contains("comment") ||
|
||||
value.contains("related") ||
|
||||
value.contains("share") ||
|
||||
value.contains("toc") ||
|
||||
value.contains("directory") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
for child in handle.children.borrow().iter() {
|
||||
extract_text_from_node_filtered(child, text);
|
||||
}
|
||||
}
|
||||
NodeData::Text { ref contents } => {
|
||||
let content = contents.borrow();
|
||||
let trimmed = content.trim();
|
||||
if !trimmed.is_empty() {
|
||||
text.push_str(&content);
|
||||
text.push(' ');
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 递归处理子节点
|
||||
for child in handle.children.borrow().iter() {
|
||||
extract_text_from_node_filtered(child, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从节点提取文本
|
||||
fn extract_text_from_node(handle: &Handle, text: &mut String) {
|
||||
match handle.data {
|
||||
NodeData::Text { ref contents } => {
|
||||
text.push_str(&contents.borrow());
|
||||
text.push(' ');
|
||||
}
|
||||
_ => {
|
||||
// 递归处理子节点
|
||||
for child in handle.children.borrow().iter() {
|
||||
extract_text_from_node(child, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从HTML内容中提取标题结构
|
||||
fn extract_headings(handle: &Handle, content: &str) -> Vec<Heading> {
|
||||
let mut headings = Vec::new();
|
||||
|
||||
// 首先尝试从article标签提取标题 - 这是语义化的文章内容区
|
||||
if let Some(article_element) = find_article_element(handle) {
|
||||
// 只从文章主体提取标题,避免其他区域
|
||||
extract_headings_from_element(&article_element, &mut headings, 0);
|
||||
} else {
|
||||
// 备选:如果找不到article标签,尝试从main提取
|
||||
if let Some(main_element) = find_main_content(handle) {
|
||||
extract_headings_from_element(&main_element, &mut headings, 0);
|
||||
} else {
|
||||
// 最后的备选:从整个文档提取,但排除header、aside、section
|
||||
extract_headings_internal(handle, &mut headings, 0);
|
||||
}
|
||||
}
|
||||
|
||||
if !headings.is_empty() {
|
||||
// 将内容转为小写用于位置匹配
|
||||
let content_lower = content.to_lowercase();
|
||||
|
||||
// 计算每个标题在内容中的位置
|
||||
for i in 0..headings.len() {
|
||||
let heading_text = headings[i].text.to_lowercase();
|
||||
|
||||
// 查找标题在内容中的位置
|
||||
if let Some(pos) = content_lower.find(&heading_text) {
|
||||
headings[i].position = pos;
|
||||
|
||||
// 计算结束位置(下一个标题的开始,或者文档结束)
|
||||
if i < headings.len() - 1 {
|
||||
headings[i].end_position = Some(headings[i + 1].position);
|
||||
} else {
|
||||
headings[i].end_position = Some(content.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
headings
|
||||
}
|
||||
// 从指定元素提取标题(通常是article标签)
|
||||
fn extract_headings_from_element(handle: &Handle, headings: &mut Vec<Heading>, position: usize) {
|
||||
match handle.data {
|
||||
NodeData::Element { ref name, .. } => {
|
||||
let tag_name = name.local.to_string();
|
||||
|
||||
// 检查是否是标题标签
|
||||
if tag_name.starts_with('h') && tag_name.len() == 2 {
|
||||
if let Some(level) = tag_name.chars().nth(1).unwrap_or('0').to_digit(10) {
|
||||
if level >= 1 && level <= 6 {
|
||||
// 提取标题文本
|
||||
let mut title_text = String::new();
|
||||
extract_text_from_node(handle, &mut title_text);
|
||||
|
||||
let trimmed_text = title_text.trim().to_string();
|
||||
|
||||
// 只添加非空标题
|
||||
if !trimmed_text.is_empty() {
|
||||
// 检查是否重复
|
||||
if !headings.iter().any(|h| h.text == trimmed_text) {
|
||||
// 创建标题对象
|
||||
headings.push(Heading {
|
||||
level: level as usize,
|
||||
text: trimmed_text,
|
||||
position,
|
||||
end_position: None, // 稍后填充
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
for child in handle.children.borrow().iter() {
|
||||
extract_headings_from_element(child, headings, position);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 递归处理子节点
|
||||
for child in handle.children.borrow().iter() {
|
||||
extract_headings_from_element(child, headings, position);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 递归辅助函数,提取标题标签 (h1, h2, h3, etc.),同时排除非内容区域
|
||||
fn extract_headings_internal(handle: &Handle, headings: &mut Vec<Heading>, position: usize) {
|
||||
match handle.data {
|
||||
NodeData::Element { ref name, ref attrs, .. } => {
|
||||
let tag_name = name.local.to_string();
|
||||
|
||||
// 排除header、aside、section(目录)标签区域
|
||||
if tag_name == "header" || tag_name == "aside" || tag_name == "section" {
|
||||
// 检查section是否是目录区域
|
||||
if tag_name == "section" {
|
||||
// 检查是否有表明这是目录的类或ID
|
||||
let attrs = attrs.borrow();
|
||||
let is_toc = attrs.iter().any(|attr| {
|
||||
(attr.name.local.to_string() == "id" && attr.value.contains("toc")) ||
|
||||
(attr.name.local.to_string() == "class" && attr.value.contains("toc"))
|
||||
});
|
||||
|
||||
// 如果不是目录,可以递归处理
|
||||
if !is_toc {
|
||||
for child in handle.children.borrow().iter() {
|
||||
extract_headings_internal(child, headings, position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不再递归处理这些区域
|
||||
return;
|
||||
}
|
||||
|
||||
// 排除sr-only元素
|
||||
let is_sr_only = attrs.borrow().iter().any(|attr| {
|
||||
attr.name.local.to_string() == "class" &&
|
||||
attr.value.contains("sr-only")
|
||||
});
|
||||
|
||||
if is_sr_only {
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理标题标签
|
||||
if tag_name.starts_with('h') && tag_name.len() == 2 {
|
||||
if let Some(level) = tag_name.chars().nth(1).unwrap_or('0').to_digit(10) {
|
||||
if level >= 1 && level <= 6 {
|
||||
// 提取标题文本
|
||||
let mut title_text = String::new();
|
||||
extract_text_from_node(handle, &mut title_text);
|
||||
|
||||
let trimmed_text = title_text.trim().to_string();
|
||||
|
||||
// 只添加非空标题
|
||||
if !trimmed_text.is_empty() {
|
||||
// 检查是否重复
|
||||
if !headings.iter().any(|h| h.text == trimmed_text) {
|
||||
// 创建标题对象
|
||||
headings.push(Heading {
|
||||
level: level as usize,
|
||||
text: trimmed_text,
|
||||
position,
|
||||
end_position: None, // 稍后填充
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
for child in handle.children.borrow().iter() {
|
||||
extract_headings_internal(child, headings, position);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 递归处理子节点
|
||||
for child in handle.children.borrow().iter() {
|
||||
extract_headings_internal(child, headings, position);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
18
wasm/geo/Cargo.toml
Normal file
18
wasm/geo/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "geo-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
geojson = { workspace = true }
|
||||
geo = { workspace = true }
|
||||
geo-types = { workspace = true }
|
||||
js-sys = { workspace = true }
|
||||
kdtree = { workspace = true }
|
||||
console_error_panic_hook = { workspace = true }
|
439
wasm/geo/src/lib.rs
Normal file
439
wasm/geo/src/lib.rs
Normal file
@ -0,0 +1,439 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use geojson::{Feature, GeoJson, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::f64::consts::PI;
|
||||
use kdtree::KdTree;
|
||||
use kdtree::distance::squared_euclidean;
|
||||
|
||||
// 初始化错误处理
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn start() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
// 表示3D向量的结构
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
pub struct Vector3 {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Vector3 {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(x: f64, y: f64, z: f64) -> Vector3 {
|
||||
Vector3 { x, y, z }
|
||||
}
|
||||
}
|
||||
|
||||
// 表示边界盒的结构
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
pub struct BoundingBox {
|
||||
pub min_x: f64,
|
||||
pub min_y: f64,
|
||||
pub min_z: f64,
|
||||
pub max_x: f64,
|
||||
pub max_y: f64,
|
||||
pub max_z: f64,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl BoundingBox {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(min_x: f64, min_y: f64, min_z: f64, max_x: f64, max_y: f64, max_z: f64) -> BoundingBox {
|
||||
BoundingBox {
|
||||
min_x, min_y, min_z,
|
||||
max_x, max_y, max_z,
|
||||
}
|
||||
}
|
||||
|
||||
// 计算点到边界盒的距离
|
||||
pub fn distance_to_point(&self, point: &Vector3) -> f64 {
|
||||
let dx = if point.x < self.min_x {
|
||||
self.min_x - point.x
|
||||
} else if point.x > self.max_x {
|
||||
point.x - self.max_x
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let dy = if point.y < self.min_y {
|
||||
self.min_y - point.y
|
||||
} else if point.y > self.max_y {
|
||||
point.y - self.max_y
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let dz = if point.z < self.min_z {
|
||||
self.min_z - point.z
|
||||
} else if point.z > self.max_z {
|
||||
point.z - self.max_z
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
(dx * dx + dy * dy + dz * dz).sqrt()
|
||||
}
|
||||
|
||||
// 获取边界盒的大小(对角线长度)
|
||||
pub fn get_size(&self) -> f64 {
|
||||
let dx = self.max_x - self.min_x;
|
||||
let dy = self.max_y - self.min_y;
|
||||
let dz = self.max_z - self.min_z;
|
||||
(dx * dx + dy * dy + dz * dz).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
// 区域信息结构
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RegionInfo {
|
||||
pub name: String,
|
||||
pub is_visited: bool,
|
||||
pub center: Vector3,
|
||||
pub bounding_box: BoundingBox,
|
||||
}
|
||||
|
||||
// 表示带有属性的边界线的结构
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BoundaryLine {
|
||||
pub points: Vec<Vector3>,
|
||||
pub region_name: String,
|
||||
pub is_visited: bool,
|
||||
}
|
||||
|
||||
// 地理处理器
|
||||
#[wasm_bindgen]
|
||||
pub struct GeoProcessor {
|
||||
region_tree: Option<KdTree<f64, String, [f64; 3]>>,
|
||||
regions: HashMap<String, RegionInfo>,
|
||||
boundary_lines: Vec<BoundaryLine>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl GeoProcessor {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> GeoProcessor {
|
||||
GeoProcessor {
|
||||
region_tree: None,
|
||||
regions: HashMap::new(),
|
||||
boundary_lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// 将经纬度转换为三维坐标
|
||||
pub fn lat_long_to_vector3(&self, lat: f64, lon: f64, radius: f64) -> Vector3 {
|
||||
// 确保经度在 -180 到 180 之间
|
||||
let lon = if lon > 180.0 { lon - 360.0 } else if lon < -180.0 { lon + 360.0 } else { lon };
|
||||
|
||||
let phi = (90.0 - lat) * PI / 180.0;
|
||||
let theta = (lon + 180.0) * PI / 180.0;
|
||||
|
||||
let x = -radius * phi.sin() * theta.cos();
|
||||
let y = radius * phi.cos();
|
||||
let z = radius * phi.sin() * theta.sin();
|
||||
|
||||
Vector3 { x, y, z }
|
||||
}
|
||||
|
||||
// 处理GeoJSON数据并构建优化的空间索引和边界线
|
||||
#[wasm_bindgen]
|
||||
pub fn process_geojson(&mut self, world_json: &str, china_json: &str, visited_places_json: &str, scale: f64) -> Result<(), JsValue> {
|
||||
// 解析访问过的地点
|
||||
let visited_places: Vec<String> = serde_json::from_str(visited_places_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Error parsing visited places: {}", e)))?;
|
||||
|
||||
// 解析世界数据
|
||||
let world_geojson: GeoJson = world_json.parse()
|
||||
.map_err(|e| JsValue::from_str(&format!("Error parsing world GeoJSON: {}", e)))?;
|
||||
|
||||
// 解析中国数据
|
||||
let china_geojson: GeoJson = china_json.parse()
|
||||
.map_err(|e| JsValue::from_str(&format!("Error parsing China GeoJSON: {}", e)))?;
|
||||
|
||||
// 创建空间索引
|
||||
let mut region_tree = KdTree::new(3);
|
||||
let mut regions = HashMap::new();
|
||||
let mut boundary_lines = Vec::new();
|
||||
|
||||
// 处理世界地图的特征
|
||||
if let GeoJson::FeatureCollection(collection) = world_geojson {
|
||||
for feature in collection.features {
|
||||
// 跳过中国,因为会用更详细的中国地图数据
|
||||
if let Some(props) = &feature.properties {
|
||||
if let Some(serde_json::Value::String(name)) = props.get("name") {
|
||||
if name == "中国" {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.process_feature(&feature, &visited_places, None, scale,
|
||||
&mut region_tree, &mut regions, &mut boundary_lines)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理中国地图数据
|
||||
if let GeoJson::FeatureCollection(collection) = china_geojson {
|
||||
for feature in collection.features {
|
||||
self.process_feature(&feature, &visited_places, Some("中国"), scale,
|
||||
&mut region_tree, &mut regions, &mut boundary_lines)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存处理结果
|
||||
self.region_tree = Some(region_tree);
|
||||
self.regions = regions;
|
||||
self.boundary_lines = boundary_lines;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 处理单个地理特征
|
||||
fn process_feature(
|
||||
&self,
|
||||
feature: &Feature,
|
||||
visited_places: &[String],
|
||||
parent_name: Option<&str>,
|
||||
scale: f64,
|
||||
region_tree: &mut KdTree<f64, String, [f64; 3]>,
|
||||
regions: &mut HashMap<String, RegionInfo>,
|
||||
boundary_lines: &mut Vec<BoundaryLine>
|
||||
) -> Result<(), JsValue> {
|
||||
if let Some(props) = &feature.properties {
|
||||
if let Some(serde_json::Value::String(name)) = props.get("name") {
|
||||
// 确定完整的区域名称
|
||||
let region_name = if let Some(parent) = parent_name {
|
||||
format!("{}-{}", parent, name)
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
|
||||
// 检查是否已访问
|
||||
let is_visited = visited_places.contains(®ion_name);
|
||||
|
||||
// 处理几何体
|
||||
if let Some(geom) = &feature.geometry {
|
||||
match &geom.value {
|
||||
Value::Polygon(polygon) => {
|
||||
self.process_polygon(polygon, ®ion_name, is_visited, scale,
|
||||
region_tree, regions, boundary_lines)?;
|
||||
}
|
||||
Value::MultiPolygon(multi_polygon) => {
|
||||
for polygon in multi_polygon {
|
||||
self.process_polygon(polygon, ®ion_name, is_visited, scale,
|
||||
region_tree, regions, boundary_lines)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 处理多边形
|
||||
fn process_polygon(
|
||||
&self,
|
||||
polygon: &Vec<Vec<Vec<f64>>>,
|
||||
region_name: &str,
|
||||
is_visited: bool,
|
||||
scale: f64,
|
||||
region_tree: &mut KdTree<f64, String, [f64; 3]>,
|
||||
regions: &mut HashMap<String, RegionInfo>,
|
||||
boundary_lines: &mut Vec<BoundaryLine>
|
||||
) -> Result<(), JsValue> {
|
||||
if polygon.is_empty() || polygon[0].is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 外环
|
||||
let exterior = &polygon[0];
|
||||
|
||||
// 计算中心点和边界盒
|
||||
let mut center_lon = 0.0;
|
||||
let mut center_lat = 0.0;
|
||||
let mut count = 0;
|
||||
|
||||
let mut min_x = f64::INFINITY;
|
||||
let mut min_y = f64::INFINITY;
|
||||
let mut min_z = f64::INFINITY;
|
||||
let mut max_x = f64::NEG_INFINITY;
|
||||
let mut max_y = f64::NEG_INFINITY;
|
||||
let mut max_z = f64::NEG_INFINITY;
|
||||
|
||||
let mut points = Vec::new();
|
||||
|
||||
// 遍历所有点
|
||||
for point in exterior {
|
||||
if point.len() >= 2 {
|
||||
let lon = point[0];
|
||||
let lat = point[1];
|
||||
|
||||
center_lon += lon;
|
||||
center_lat += lat;
|
||||
count += 1;
|
||||
|
||||
// 转换为3D坐标
|
||||
let vertex = self.lat_long_to_vector3(lat, lon, scale);
|
||||
points.push(vertex);
|
||||
|
||||
// 更新边界盒
|
||||
min_x = min_x.min(vertex.x);
|
||||
min_y = min_y.min(vertex.y);
|
||||
min_z = min_z.min(vertex.z);
|
||||
max_x = max_x.max(vertex.x);
|
||||
max_y = max_y.max(vertex.y);
|
||||
max_z = max_z.max(vertex.z);
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// 计算中心点
|
||||
center_lon /= count as f64;
|
||||
center_lat /= count as f64;
|
||||
|
||||
// 创建中心点3D坐标
|
||||
let center = self.lat_long_to_vector3(center_lat, center_lon, scale + 0.005);
|
||||
|
||||
// 创建边界盒
|
||||
let bounding_box = BoundingBox {
|
||||
min_x, min_y, min_z,
|
||||
max_x, max_y, max_z,
|
||||
};
|
||||
|
||||
// 保存区域信息
|
||||
let region_info = RegionInfo {
|
||||
name: region_name.to_string(),
|
||||
is_visited,
|
||||
center,
|
||||
bounding_box,
|
||||
};
|
||||
|
||||
// 添加到区域索引
|
||||
regions.insert(region_name.to_string(), region_info);
|
||||
|
||||
// 添加到KD树
|
||||
let coord_key = [center.x, center.y, center.z];
|
||||
region_tree.add(coord_key, region_name.to_string())
|
||||
.map_err(|e| JsValue::from_str(&format!("Error adding to KD tree: {}", e)))?;
|
||||
|
||||
// 创建边界线
|
||||
if points.len() > 1 {
|
||||
let boundary_line = BoundaryLine {
|
||||
points,
|
||||
region_name: region_name.to_string(),
|
||||
is_visited,
|
||||
};
|
||||
|
||||
boundary_lines.push(boundary_line);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 查找最近的国家/地区
|
||||
#[wasm_bindgen]
|
||||
pub fn find_nearest_country(&self, point_x: f64, point_y: f64, point_z: f64, _radius: f64) -> Option<String> {
|
||||
let point = Vector3 { x: point_x, y: point_y, z: point_z };
|
||||
|
||||
// 先检查点是否在任何边界盒内
|
||||
for (name, region) in &self.regions {
|
||||
if region.bounding_box.distance_to_point(&point) < 0.001 {
|
||||
return Some(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 全局最近区域
|
||||
let mut closest_name = None;
|
||||
let mut min_distance = f64::INFINITY;
|
||||
let mut small_region_distance = f64::INFINITY;
|
||||
let mut small_region_name = None;
|
||||
|
||||
// KD树搜索的数量
|
||||
const K_NEAREST: usize = 10;
|
||||
|
||||
// 使用KD树搜索最近的区域
|
||||
if let Some(tree) = &self.region_tree {
|
||||
if let Ok(nearest) = tree.nearest(&[point.x, point.y, point.z], K_NEAREST, &squared_euclidean) {
|
||||
for (dist, name) in nearest {
|
||||
// 转换距离
|
||||
let distance = dist.sqrt();
|
||||
|
||||
// 检查是否更接近
|
||||
if distance < min_distance {
|
||||
min_distance = distance;
|
||||
closest_name = Some(name.clone());
|
||||
}
|
||||
|
||||
// 处理小区域逻辑
|
||||
if let Some(region) = self.regions.get(name) {
|
||||
let box_size = region.bounding_box.get_size();
|
||||
|
||||
// 如果是小区域,使用加权距离
|
||||
const SMALL_REGION_THRESHOLD: f64 = 0.5;
|
||||
if box_size < SMALL_REGION_THRESHOLD {
|
||||
let weighted_distance = distance * (0.5 + box_size / 2.0);
|
||||
if weighted_distance < small_region_distance {
|
||||
small_region_distance = weighted_distance;
|
||||
small_region_name = Some(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 小区域优化逻辑
|
||||
if let Some(name) = &small_region_name {
|
||||
if small_region_distance < min_distance * 2.0 {
|
||||
return Some(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 处理中国的特殊情况
|
||||
if let Some(name) = &closest_name {
|
||||
if name == "中国" {
|
||||
// 查找最近的中国省份
|
||||
let mut closest_province = None;
|
||||
let mut min_province_distance = f64::INFINITY;
|
||||
|
||||
for (region_name, region) in &self.regions {
|
||||
if region_name.starts_with("中国-") {
|
||||
let distance = region.bounding_box.distance_to_point(&point);
|
||||
if distance < min_province_distance {
|
||||
min_province_distance = distance;
|
||||
closest_province = Some(region_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(province) = closest_province {
|
||||
if min_province_distance < min_distance * 1.5 {
|
||||
return Some(province);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closest_name
|
||||
}
|
||||
|
||||
// 获取边界线数据,用于在JS中渲染
|
||||
#[wasm_bindgen]
|
||||
pub fn get_boundary_lines(&self) -> JsValue {
|
||||
serde_wasm_bindgen::to_value(&self.boundary_lines).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
// 获取所有区域信息
|
||||
#[wasm_bindgen]
|
||||
pub fn get_regions(&self) -> JsValue {
|
||||
serde_wasm_bindgen::to_value(&self.regions).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
}
|
23
wasm/readme.md
Normal file
23
wasm/readme.md
Normal file
@ -0,0 +1,23 @@
|
||||
# 构建命令
|
||||
|
||||
## 构建wasm
|
||||
|
||||
```bash
|
||||
wasm-pack build --target web
|
||||
```
|
||||
|
||||
## 构建应用
|
||||
|
||||
### windows
|
||||
|
||||
```bash
|
||||
cargo build --release --target x86_64-pc-windows-msvc
|
||||
```
|
||||
|
||||
> 如果在window上交叉编译先安装Linux工具链,cross,wsl,docker
|
||||
|
||||
### linux
|
||||
|
||||
```bash
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
```
|
25
wasm/search/Cargo.toml
Normal file
25
wasm/search/Cargo.toml
Normal file
@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "search-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "WebAssembly module for article search functionality"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
js-sys.workspace = true
|
||||
bincode.workspace = true
|
||||
flate2.workspace = true
|
||||
console_error_panic_hook.workspace = true
|
||||
web-sys = { workspace = true, features = ["console", "Window", "Performance"] }
|
||||
regex.workspace = true
|
||||
utils-common = { path = "../utils-common" }
|
||||
|
||||
# 优化WebAssembly二进制大小
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = "s"
|
483
wasm/search/src/builder.rs
Normal file
483
wasm/search/src/builder.rs
Normal file
@ -0,0 +1,483 @@
|
||||
use utils_common::models::ArticleMetadata;
|
||||
use utils_common::compression::to_compressed;
|
||||
use crate::models::{ArticleSearchIndex, HeadingIndexEntry};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use regex::Regex;
|
||||
|
||||
/// 简单移除字符串中的HTML标签
|
||||
fn remove_html_tags(text: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut in_tag = false;
|
||||
|
||||
for c in text.chars() {
|
||||
if c == '<' {
|
||||
in_tag = true;
|
||||
} else if c == '>' {
|
||||
in_tag = false;
|
||||
} else if !in_tag {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
/// 搜索索引构建器
|
||||
pub struct SearchBuilder {
|
||||
articles: Vec<ArticleMetadata>,
|
||||
}
|
||||
|
||||
impl SearchBuilder {
|
||||
/// 创建新的搜索索引构建器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
articles: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取索引构建器中的文章数量
|
||||
pub fn get_article_count(&self) -> usize {
|
||||
self.articles.len()
|
||||
}
|
||||
|
||||
/// 添加文章到索引构建器
|
||||
pub fn add_article(&mut self, article: ArticleMetadata) {
|
||||
// 只添加非目录页面到索引
|
||||
if article.page_type != "directory" {
|
||||
self.articles.push(article);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理文本,移除不必要的字符和符号
|
||||
fn clean_text(&self, text: &str) -> String {
|
||||
text.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// 提取关键词
|
||||
fn extract_keywords(&self, text: &str) -> Vec<String> {
|
||||
let clean_text = self.clean_text(text);
|
||||
|
||||
let mut keywords = HashSet::new();
|
||||
let mut current_word = String::new();
|
||||
let mut chinese_chars = Vec::new();
|
||||
|
||||
// 遍历文本字符
|
||||
for c in clean_text.chars() {
|
||||
if c.is_alphanumeric() || c == '_' || c == '-' {
|
||||
// 如果之前有收集的中文字符,先处理
|
||||
if !chinese_chars.is_empty() {
|
||||
// 处理中文词组 (2-3个字符组合)
|
||||
for i in 1..=chinese_chars.len().min(3) {
|
||||
for start in 0..=chinese_chars.len() - i {
|
||||
let term: String = chinese_chars[start..start+i].iter().collect();
|
||||
if term.len() >= 2 { // 只添加长度>=2的中文词
|
||||
keywords.insert(term);
|
||||
}
|
||||
}
|
||||
}
|
||||
chinese_chars.clear();
|
||||
}
|
||||
|
||||
current_word.push(c);
|
||||
} else if c.is_whitespace() || c.is_ascii_punctuation() {
|
||||
// 处理当前单词
|
||||
if !current_word.is_empty() && current_word.len() >= 2 {
|
||||
keywords.insert(current_word.clone());
|
||||
current_word.clear();
|
||||
}
|
||||
|
||||
// 处理中文字符
|
||||
if !chinese_chars.is_empty() {
|
||||
for i in 1..=chinese_chars.len().min(3) {
|
||||
for start in 0..=chinese_chars.len() - i {
|
||||
let term: String = chinese_chars[start..start+i].iter().collect();
|
||||
if term.len() >= 2 {
|
||||
keywords.insert(term);
|
||||
}
|
||||
}
|
||||
}
|
||||
chinese_chars.clear();
|
||||
}
|
||||
} else {
|
||||
// 处理当前英文词
|
||||
if !current_word.is_empty() && current_word.len() >= 2 {
|
||||
keywords.insert(current_word.clone());
|
||||
current_word.clear();
|
||||
}
|
||||
|
||||
// 收集中文字符
|
||||
chinese_chars.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后一个单词
|
||||
if !current_word.is_empty() && current_word.len() >= 2 {
|
||||
keywords.insert(current_word);
|
||||
}
|
||||
|
||||
// 处理最后的中文字符
|
||||
if !chinese_chars.is_empty() {
|
||||
for i in 1..=chinese_chars.len().min(3) {
|
||||
for start in 0..=chinese_chars.len() - i {
|
||||
let term: String = chinese_chars[start..start+i].iter().collect();
|
||||
if term.len() >= 2 {
|
||||
keywords.insert(term);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤纯数字的关键词
|
||||
keywords.into_iter()
|
||||
.filter(|keyword| !keyword.chars().all(|c| c.is_ascii_digit()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 提取文章中的标题和层级结构
|
||||
fn extract_headings(&self, article: &ArticleMetadata, article_id: usize) -> HashMap<String, HeadingIndexEntry> {
|
||||
let headings = HashMap::new();
|
||||
|
||||
// 如果内容为空,返回空结果
|
||||
if article.content.is_empty() {
|
||||
return headings;
|
||||
}
|
||||
|
||||
// 首先尝试使用文章中已解析的标题(如果有)
|
||||
if !article.headings.is_empty() {
|
||||
return self.build_heading_structure_from_extracted(&article.headings, article);
|
||||
}
|
||||
|
||||
// 使用正则表达式匹配所有h1-h6标签
|
||||
let heading_regex = Regex::new(r"<h([1-6])(?:\s+[^>]*)?>([\s\S]*?)</h\d>").unwrap();
|
||||
|
||||
// 提取所有标题及其位置和级别
|
||||
let mut extracted_headings = Vec::new();
|
||||
|
||||
for cap in heading_regex.captures_iter(&article.content) {
|
||||
// 获取标题级别
|
||||
let level = cap.get(1)
|
||||
.and_then(|m| m.as_str().parse::<usize>().ok())
|
||||
.unwrap_or(1);
|
||||
|
||||
// 获取标题文本并清理HTML标签
|
||||
let text_with_tags = cap.get(2).map_or("", |m| m.as_str());
|
||||
let text = remove_html_tags(text_with_tags).trim().to_string();
|
||||
|
||||
// 跳过空标题
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 记录标题在文档中的位置
|
||||
let position = cap.get(0).map_or(0, |m| m.start());
|
||||
|
||||
// 添加到提取的标题列表
|
||||
extracted_headings.push((level, text, position));
|
||||
}
|
||||
|
||||
// 如果没有找到标题,尝试使用更宽松的正则表达式
|
||||
if extracted_headings.is_empty() {
|
||||
// 使用最宽松的正则表达式再次尝试匹配
|
||||
let fallback_regex = Regex::new(r"<h\d[^>]*>(.*?)</h\d>").unwrap();
|
||||
|
||||
for cap in fallback_regex.captures_iter(&article.content) {
|
||||
let text_with_tags = cap.get(1).map_or("", |m| m.as_str());
|
||||
let text = remove_html_tags(text_with_tags).trim().to_string();
|
||||
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let position = cap.get(0).map_or(0, |m| m.start());
|
||||
// 默认级别为1
|
||||
extracted_headings.push((1, text, position));
|
||||
}
|
||||
}
|
||||
|
||||
// 如果仍然没有找到标题,返回空结果
|
||||
if extracted_headings.is_empty() {
|
||||
return headings;
|
||||
}
|
||||
|
||||
// 按位置排序提取的标题
|
||||
extracted_headings.sort_by_key(|h| h.2);
|
||||
|
||||
// 构建标题层级结构
|
||||
self.build_heading_hierarchy(extracted_headings, article)
|
||||
}
|
||||
|
||||
/// 从提取的标题数组构建标题层级结构
|
||||
fn build_heading_hierarchy(
|
||||
&self,
|
||||
sorted_headings: Vec<(usize, String, usize)>, // (级别, 文本, 位置)
|
||||
article: &ArticleMetadata
|
||||
) -> HashMap<String, HeadingIndexEntry> {
|
||||
let mut result = HashMap::new();
|
||||
let mut heading_stack: Vec<(String, usize)> = Vec::new(); // (ID, 级别)
|
||||
let mut children_map: HashMap<String, Vec<String>> = HashMap::new(); // 存储子标题关系
|
||||
|
||||
// 遍历排序后的标题,构建层级关系
|
||||
for (idx, (level, text, position)) in sorted_headings.iter().enumerate() {
|
||||
let heading_id = format!("{}:{}", article.id, idx);
|
||||
|
||||
// 确定结束位置 - 下一个标题的开始或文章结束
|
||||
let end_position = if idx + 1 < sorted_headings.len() {
|
||||
sorted_headings[idx + 1].2
|
||||
} else {
|
||||
article.content.len()
|
||||
};
|
||||
|
||||
// 查找父标题: 向上查找堆栈中第一个级别小于当前标题的条目
|
||||
let mut parent_id = None;
|
||||
|
||||
// 从栈顶开始,移除所有级别>=当前标题的条目
|
||||
while let Some((last_id, last_level)) = heading_stack.last() {
|
||||
if *last_level >= *level {
|
||||
heading_stack.pop();
|
||||
} else {
|
||||
parent_id = Some(last_id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有父标题,添加到父标题的子标题列表
|
||||
if let Some(ref pid) = parent_id {
|
||||
children_map.entry(pid.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(heading_id.clone());
|
||||
}
|
||||
|
||||
// 创建标题条目
|
||||
let heading_entry = HeadingIndexEntry {
|
||||
id: heading_id.clone(),
|
||||
level: *level,
|
||||
text: text.clone(),
|
||||
start_position: *position,
|
||||
end_position,
|
||||
parent_id,
|
||||
children_ids: Vec::new(), // 暂时为空,稍后填充
|
||||
};
|
||||
|
||||
// 将当前标题入栈
|
||||
heading_stack.push((heading_id.clone(), *level));
|
||||
|
||||
// 添加到结果集
|
||||
result.insert(heading_id, heading_entry);
|
||||
}
|
||||
|
||||
// 获取所有标题的位置信息
|
||||
let mut position_map = HashMap::new();
|
||||
for (id, entry) in &result {
|
||||
position_map.insert(id.clone(), entry.start_position);
|
||||
}
|
||||
|
||||
// 填充并排序子标题列表
|
||||
for (parent_id, children) in children_map {
|
||||
if let Some(parent) = result.get_mut(&parent_id) {
|
||||
// 添加子标题
|
||||
parent.children_ids = children;
|
||||
|
||||
// 按位置排序
|
||||
parent.children_ids.sort_by(|a, b| {
|
||||
let pos_a = position_map.get(a).cloned().unwrap_or(0);
|
||||
let pos_b = position_map.get(b).cloned().unwrap_or(0);
|
||||
pos_a.cmp(&pos_b)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 从预解析的标题列表构建层级结构
|
||||
fn build_heading_structure_from_extracted(
|
||||
&self,
|
||||
headings: &[utils_common::models::Heading],
|
||||
article: &ArticleMetadata
|
||||
) -> HashMap<String, HeadingIndexEntry> {
|
||||
// 将预解析的标题转换为(级别, 文本, 位置)的格式
|
||||
let mut extracted: Vec<(usize, String, usize)> = headings.iter()
|
||||
.map(|h| (h.level, h.text.clone(), h.position))
|
||||
.collect();
|
||||
|
||||
// 按位置排序
|
||||
extracted.sort_by_key(|h| h.2);
|
||||
|
||||
// 构建层级结构
|
||||
self.build_heading_hierarchy(extracted, article)
|
||||
}
|
||||
|
||||
/// 构建搜索索引
|
||||
pub fn build_search_index(&self) -> Result<ArticleSearchIndex, String> {
|
||||
if self.articles.is_empty() {
|
||||
return Err("无法构建索引: 没有文章数据".to_string());
|
||||
}
|
||||
|
||||
// 构建标题关键词到文章的索引
|
||||
let title_term_index = self.build_title_term_index();
|
||||
|
||||
// 提取所有文章的标题结构
|
||||
let mut all_headings = HashMap::new();
|
||||
|
||||
for (article_id, article) in self.articles.iter().enumerate() {
|
||||
// 提取标题结构
|
||||
let article_headings = self.extract_headings(article, article_id);
|
||||
|
||||
// 合并到全局索引
|
||||
all_headings.extend(article_headings);
|
||||
}
|
||||
|
||||
// 构建标题关键词索引
|
||||
let heading_term_index = self.build_heading_term_index(&all_headings);
|
||||
|
||||
// 定义停用词表
|
||||
let stop_words: HashSet<&str> = [
|
||||
"的", "是", "在", "了", "和", "与", "或", "而", "但", "如果", "因为",
|
||||
"所以", "这", "那", "这个", "那个", "这些", "那些", "并", "可以", "把",
|
||||
"被", "将", "已", "就", "也", "很", "到", "上", "下", "中", "为"
|
||||
].iter().cloned().collect();
|
||||
|
||||
// 统计词频
|
||||
let mut term_frequency: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
// 构建内容关键词索引
|
||||
let mut content_term_index: HashMap<String, HashSet<usize>> = HashMap::new();
|
||||
|
||||
// 遍历所有文章,提取关键词和构建内容索引
|
||||
for (article_id, article) in self.articles.iter().enumerate() {
|
||||
// 标题关键词
|
||||
let title_keywords = self.extract_keywords(&article.title);
|
||||
for keyword in &title_keywords {
|
||||
if !stop_words.contains(keyword.as_str()) && keyword.len() >= 2 {
|
||||
*term_frequency.entry(keyword.clone()).or_insert(0) += 3; // 标题权重高
|
||||
}
|
||||
}
|
||||
|
||||
// 内容关键词
|
||||
let content_keywords = self.extract_keywords(&article.content);
|
||||
let mut content_term_freq: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
// 先统计文章内的词频
|
||||
for keyword in content_keywords {
|
||||
if !stop_words.contains(keyword.as_str()) && keyword.len() >= 2 {
|
||||
*content_term_freq.entry(keyword.clone()).or_insert(0) += 1;
|
||||
|
||||
// 同时添加到内容关键词索引
|
||||
content_term_index.entry(keyword)
|
||||
.or_insert_with(HashSet::new)
|
||||
.insert(article_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 只保留高频词(出现至少2次)添加到全局词频统计
|
||||
for (keyword, freq) in content_term_freq.iter() {
|
||||
if *freq >= 2 {
|
||||
*term_frequency.entry(keyword.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 选择最常用的词作为常用词汇
|
||||
let mut terms: Vec<(String, usize)> = term_frequency.into_iter().collect();
|
||||
terms.sort_by(|a, b| b.1.cmp(&a.1)); // 按频率降序排序
|
||||
|
||||
let mut common_terms = HashMap::new();
|
||||
|
||||
// 添加常用词
|
||||
for (term, freq) in terms.into_iter().take(500) {
|
||||
common_terms.insert(term, freq);
|
||||
}
|
||||
|
||||
// 输出构建统计
|
||||
println!("索引构建统计:");
|
||||
println!("- 文章数量: {}", self.articles.len());
|
||||
println!("- 标题词汇: {}", title_term_index.len());
|
||||
println!("- 标题结构: {}", all_headings.len());
|
||||
println!("- 内容词汇: {}", content_term_index.len());
|
||||
println!("- 常用词汇: {}", common_terms.len());
|
||||
|
||||
Ok(ArticleSearchIndex {
|
||||
title_term_index,
|
||||
articles: self.articles.clone(),
|
||||
heading_index: all_headings,
|
||||
heading_term_index,
|
||||
common_terms,
|
||||
content_term_index,
|
||||
})
|
||||
}
|
||||
|
||||
/// 保存搜索索引到文件
|
||||
pub fn save_search_index(&self, path: &str) -> Result<(), String> {
|
||||
// 构建搜索索引
|
||||
let search_index = self.build_search_index()?;
|
||||
|
||||
// 保存搜索索引
|
||||
let mut search_file = File::create(path)
|
||||
.map_err(|e| format!("无法创建搜索索引文件: {}", e))?;
|
||||
|
||||
// 使用版本号7.0,表示优化版本索引
|
||||
let version = [7, 0];
|
||||
let compressed_data = to_compressed(&search_index, version)
|
||||
.map_err(|e| format!("压缩搜索索引失败: {}", e))?;
|
||||
|
||||
search_file.write_all(&compressed_data)
|
||||
.map_err(|e| format!("无法写入搜索索引文件: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 构建标题关键词索引
|
||||
fn build_heading_term_index(&self, headings: &HashMap<String, HeadingIndexEntry>) -> HashMap<String, HashSet<String>> {
|
||||
let mut heading_term_index = HashMap::new();
|
||||
|
||||
for (heading_id, heading) in headings {
|
||||
// 提取标题文本的关键词
|
||||
let heading_keywords = self.extract_keywords(&heading.text);
|
||||
|
||||
// 添加到索引
|
||||
for keyword in heading_keywords {
|
||||
heading_term_index.entry(keyword)
|
||||
.or_insert_with(HashSet::new)
|
||||
.insert(heading_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
heading_term_index
|
||||
}
|
||||
|
||||
/// 构建标题关键词到文章的索引
|
||||
fn build_title_term_index(&self) -> HashMap<String, HashSet<usize>> {
|
||||
let mut title_term_index = HashMap::new();
|
||||
|
||||
for (article_id, article) in self.articles.iter().enumerate() {
|
||||
// 从标题中提取关键词
|
||||
let title_keywords = self.extract_keywords(&article.title);
|
||||
|
||||
// 添加到索引
|
||||
for keyword in title_keywords {
|
||||
title_term_index.entry(keyword)
|
||||
.or_insert_with(HashSet::new)
|
||||
.insert(article_id);
|
||||
}
|
||||
|
||||
// 额外处理:标题中的各个单词
|
||||
let title_words: Vec<String> = article.title
|
||||
.to_lowercase()
|
||||
.split_whitespace()
|
||||
.map(|s| s.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-'))
|
||||
.filter(|s| s.len() >= 2)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
for word in title_words {
|
||||
title_term_index.entry(word)
|
||||
.or_insert_with(HashSet::new)
|
||||
.insert(article_id);
|
||||
}
|
||||
}
|
||||
|
||||
title_term_index
|
||||
}
|
||||
}
|
1024
wasm/search/src/lib.rs
Normal file
1024
wasm/search/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
159
wasm/search/src/models.rs
Normal file
159
wasm/search/src/models.rs
Normal file
@ -0,0 +1,159 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use bincode::{Encode, Decode};
|
||||
use utils_common::models::ArticleMetadata;
|
||||
|
||||
/// 标题索引项
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Encode, Decode)]
|
||||
pub struct HeadingIndexEntry {
|
||||
/// 标题ID (文章ID:标题索引)
|
||||
pub id: String,
|
||||
/// 标题级别
|
||||
pub level: usize,
|
||||
/// 标题文本
|
||||
pub text: String,
|
||||
/// 标题内容起始位置
|
||||
pub start_position: usize,
|
||||
/// 标题内容结束位置
|
||||
pub end_position: usize,
|
||||
/// 父标题ID (如果有)
|
||||
pub parent_id: Option<String>,
|
||||
/// 子标题ID列表
|
||||
pub children_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 带有匹配内容的标题节点
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct HeadingNode {
|
||||
/// 标题ID
|
||||
pub id: String,
|
||||
/// 标题文本
|
||||
pub text: String,
|
||||
/// 标题级别
|
||||
pub level: usize,
|
||||
/// 该标题下匹配的内容
|
||||
pub content: Option<String>,
|
||||
/// 匹配的关键词列表
|
||||
pub matched_terms: Option<Vec<String>>,
|
||||
/// 子标题列表
|
||||
pub children: Vec<HeadingNode>,
|
||||
}
|
||||
|
||||
/// 搜索索引 - 简化版本
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ArticleSearchIndex {
|
||||
/// 关键词到文章ID的映射(标题)
|
||||
pub title_term_index: HashMap<String, HashSet<usize>>,
|
||||
/// 文章的元数据列表
|
||||
pub articles: Vec<ArticleMetadata>,
|
||||
/// 标题索引 - 标题ID到标题信息的映射
|
||||
pub heading_index: HashMap<String, HeadingIndexEntry>,
|
||||
/// 关键词到标题ID的映射
|
||||
pub heading_term_index: HashMap<String, HashSet<String>>,
|
||||
/// 常用词汇及其频率
|
||||
pub common_terms: HashMap<String, usize>,
|
||||
/// 内容关键词到文章ID的映射
|
||||
pub content_term_index: HashMap<String, HashSet<usize>>,
|
||||
}
|
||||
|
||||
/// 搜索请求结构
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchRequest {
|
||||
/// 搜索查询
|
||||
pub query: String,
|
||||
/// 搜索类型 (normal或autocomplete)
|
||||
#[serde(default)]
|
||||
pub search_type: String,
|
||||
/// 当前页码
|
||||
#[serde(default = "default_page")]
|
||||
pub page: usize,
|
||||
/// 每页条数
|
||||
#[serde(default = "default_page_size")]
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
/// 搜索建议类型
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SuggestionType {
|
||||
/// 补全建议 - 前缀匹配
|
||||
Completion,
|
||||
/// 纠正建议 - 编辑距离或包含匹配
|
||||
Correction
|
||||
}
|
||||
|
||||
/// 搜索建议分数和类型(内部使用)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SuggestionCandidate {
|
||||
/// 建议文本
|
||||
pub text: String,
|
||||
/// 分数 (0-100)
|
||||
pub score: i32,
|
||||
/// 建议类型
|
||||
pub suggestion_type: SuggestionType,
|
||||
/// 原始关键词频率
|
||||
pub frequency: usize,
|
||||
}
|
||||
|
||||
/// 搜索建议结构(对外输出)
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct SearchSuggestion {
|
||||
/// 建议文本
|
||||
pub text: String,
|
||||
/// 建议类型
|
||||
pub suggestion_type: SuggestionType,
|
||||
/// 用户已输入匹配部分
|
||||
pub matched_text: String,
|
||||
/// 建议补全部分
|
||||
pub suggestion_text: String,
|
||||
}
|
||||
|
||||
/// 搜索结果
|
||||
#[derive(Serialize)]
|
||||
pub struct SearchResult {
|
||||
/// 搜索结果条目
|
||||
pub items: Vec<SearchResultItem>,
|
||||
/// 结果总数
|
||||
pub total: usize,
|
||||
/// 当前页码
|
||||
pub page: usize,
|
||||
/// 每页条数
|
||||
pub page_size: usize,
|
||||
/// 总页数
|
||||
pub total_pages: usize,
|
||||
/// 搜索耗时(毫秒)
|
||||
pub time_ms: usize,
|
||||
/// 搜索查询
|
||||
pub query: String,
|
||||
/// 搜索建议
|
||||
pub suggestions: Vec<SearchSuggestion>,
|
||||
}
|
||||
|
||||
/// 搜索结果条目
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct SearchResultItem {
|
||||
/// 文章ID
|
||||
pub id: String,
|
||||
/// 文章标题
|
||||
pub title: String,
|
||||
/// 文章摘要
|
||||
pub summary: String,
|
||||
/// 文章URL
|
||||
pub url: String,
|
||||
/// 匹配分数
|
||||
pub score: f64,
|
||||
/// 结构化的标题和内容层级
|
||||
pub heading_tree: Option<HeadingNode>,
|
||||
/// 页面类型
|
||||
pub page_type: String,
|
||||
}
|
||||
|
||||
/// 默认页码
|
||||
fn default_page() -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
/// 默认每页条数
|
||||
fn default_page_size() -> usize {
|
||||
10
|
||||
}
|
15
wasm/utils-common/Cargo.toml
Normal file
15
wasm/utils-common/Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "utils-common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Common utilities for article processing WASM modules"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
# 这些依赖是压缩和序列化功能所必需的
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib"]
|
150
wasm/utils-common/src/compression.rs
Normal file
150
wasm/utils-common/src/compression.rs
Normal file
@ -0,0 +1,150 @@
|
||||
use std::io::{self, Read};
|
||||
use flate2::{Compression, write::GzEncoder, read::GzDecoder};
|
||||
|
||||
/// 魔数常量 - 用于标识文件格式
|
||||
pub const MAGIC_BYTES: &'static [u8] = b"NECMP"; // NewEchoes Compressed
|
||||
|
||||
/// 将对象序列化为二进制格式
|
||||
pub fn to_binary<T: serde::Serialize>(obj: &T) -> Result<Vec<u8>, io::Error> {
|
||||
// 直接使用bincode标准配置序列化原始对象
|
||||
bincode::serde::encode_to_vec(obj, bincode::config::standard())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("序列化失败: {}", e)))
|
||||
}
|
||||
|
||||
/// 从二进制格式反序列化对象
|
||||
pub fn from_binary<T: for<'a> serde::de::Deserialize<'a>>(data: &[u8]) -> Result<T, io::Error> {
|
||||
// 使用bincode标准配置从二进制数据反序列化对象
|
||||
bincode::serde::decode_from_slice(data, bincode::config::standard())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("反序列化失败: {}", e)))
|
||||
.map(|(value, _)| value)
|
||||
}
|
||||
|
||||
/// 将对象序列化为压缩的二进制格式
|
||||
pub fn to_compressed<T: serde::Serialize>(obj: &T, version: [u8; 2]) -> Result<Vec<u8>, io::Error> {
|
||||
// 序列化
|
||||
let binary = to_binary(obj)?;
|
||||
|
||||
// 创建输出缓冲区并写入魔数
|
||||
let mut output = Vec::with_capacity(binary.len() / 2);
|
||||
output.extend_from_slice(MAGIC_BYTES);
|
||||
|
||||
// 写入版本号
|
||||
output.extend_from_slice(&version);
|
||||
|
||||
// 写入原始数据大小
|
||||
let data_len = (binary.len() as u32).to_le_bytes();
|
||||
output.extend_from_slice(&data_len);
|
||||
|
||||
// 压缩数据
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
|
||||
std::io::Write::write_all(&mut encoder, &binary)?;
|
||||
let compressed_data = encoder.finish()?;
|
||||
|
||||
// 添加压缩后的数据
|
||||
output.extend_from_slice(&compressed_data);
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// 从压缩的二进制格式反序列化对象,使用默认最大版本4
|
||||
pub fn from_compressed<T: for<'a> serde::de::Deserialize<'a>>(data: &[u8]) -> Result<T, io::Error> {
|
||||
from_compressed_with_max_version(data, 4)
|
||||
}
|
||||
|
||||
/// 从压缩的二进制格式反序列化对象,允许指定支持的最大版本
|
||||
pub fn from_compressed_with_max_version<T: for<'a> serde::de::Deserialize<'a>>(
|
||||
data: &[u8],
|
||||
max_version: u8
|
||||
) -> Result<T, io::Error> {
|
||||
// 检查数据长度是否足够
|
||||
if data.len() < MAGIC_BYTES.len() + 2 + 4 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("数据太短,无法解析: {} 字节", data.len())
|
||||
));
|
||||
}
|
||||
|
||||
// 验证魔数
|
||||
if &data[0..MAGIC_BYTES.len()] != MAGIC_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"无效的文件格式:魔数不匹配"
|
||||
));
|
||||
}
|
||||
|
||||
// 读取版本号
|
||||
let version_offset = MAGIC_BYTES.len();
|
||||
let version = [data[version_offset], data[version_offset + 1]];
|
||||
|
||||
// 验证版本兼容性
|
||||
if version[0] > max_version {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("不支持的版本: {}.{}", version[0], version[1])
|
||||
));
|
||||
}
|
||||
|
||||
// 读取原始数据大小
|
||||
let size_offset = version_offset + 2;
|
||||
let mut size_bytes = [0u8; 4];
|
||||
size_bytes.copy_from_slice(&data[size_offset..size_offset + 4]);
|
||||
let original_size = u32::from_le_bytes(size_bytes);
|
||||
|
||||
// 提取压缩数据
|
||||
let compressed_data = &data[size_offset + 4..];
|
||||
|
||||
// 解压数据
|
||||
let mut decoder = GzDecoder::new(compressed_data);
|
||||
let mut decompressed_data = Vec::with_capacity(original_size as usize);
|
||||
decoder.read_to_end(&mut decompressed_data)?;
|
||||
|
||||
// 检查解压后的数据大小
|
||||
if decompressed_data.len() != original_size as usize {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("解压后数据大小不匹配: 期望 {} 字节, 实际 {} 字节",
|
||||
original_size, decompressed_data.len())
|
||||
));
|
||||
}
|
||||
|
||||
// 反序列化数据
|
||||
from_binary(&decompressed_data)
|
||||
}
|
||||
|
||||
/// 验证压缩数据是否有效
|
||||
pub fn validate_compressed_data(data: &[u8]) -> Result<[u8; 2], io::Error> {
|
||||
validate_compressed_data_with_max_version(data, 4)
|
||||
}
|
||||
|
||||
/// 验证压缩数据是否有效,允许指定支持的最大版本
|
||||
pub fn validate_compressed_data_with_max_version(data: &[u8], max_version: u8) -> Result<[u8; 2], io::Error> {
|
||||
// 检查数据长度是否足够
|
||||
if data.len() < MAGIC_BYTES.len() + 2 + 4 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("数据太短,无法验证: {} 字节", data.len())
|
||||
));
|
||||
}
|
||||
|
||||
// 验证魔数
|
||||
if &data[0..MAGIC_BYTES.len()] != MAGIC_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"无效的文件格式:魔数不匹配"
|
||||
));
|
||||
}
|
||||
|
||||
// 读取版本号
|
||||
let version_offset = MAGIC_BYTES.len();
|
||||
let version = [data[version_offset], data[version_offset + 1]];
|
||||
|
||||
// 验证版本兼容性
|
||||
if version[0] > max_version {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("不支持的版本: {}.{}", version[0], version[1])
|
||||
));
|
||||
}
|
||||
|
||||
Ok(version)
|
||||
}
|
6
wasm/utils-common/src/lib.rs
Normal file
6
wasm/utils-common/src/lib.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod compression;
|
||||
pub mod models;
|
||||
|
||||
// 重新导出常用模块和函数,方便直接使用
|
||||
pub use compression::{to_compressed, from_compressed, to_binary, from_binary, validate_compressed_data};
|
||||
pub use models::{ArticleMetadata, Heading, IndexType, IndexMetadata};
|
93
wasm/utils-common/src/models.rs
Normal file
93
wasm/utils-common/src/models.rs
Normal file
@ -0,0 +1,93 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 标题结构 - 存储文章中的标题及其层级
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Heading {
|
||||
/// 标题级别(1表示h1,2表示h2,依此类推)
|
||||
pub level: usize,
|
||||
/// 标题文本
|
||||
pub text: String,
|
||||
/// 标题在文章中的开始位置(字符偏移量)
|
||||
pub position: usize,
|
||||
/// 标题内容结束位置(下一个标题开始前或文章结束)
|
||||
pub end_position: Option<usize>,
|
||||
}
|
||||
|
||||
/// 文章元数据 - 存储索引所需的文章基本信息
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ArticleMetadata {
|
||||
/// 文章唯一标识符
|
||||
pub id: String,
|
||||
/// 文章标题
|
||||
pub title: String,
|
||||
/// 文章摘要
|
||||
pub summary: String,
|
||||
/// 发布日期
|
||||
pub date: DateTime<Utc>,
|
||||
/// 文章标签列表
|
||||
pub tags: Vec<String>,
|
||||
/// 文章URL路径
|
||||
pub url: String,
|
||||
/// 文章内容,用于全文搜索
|
||||
#[serde(skip_serializing_if = "String::is_empty", default)]
|
||||
pub content: String,
|
||||
/// 页面类型:article(文章)、page(普通页面)
|
||||
#[serde(default = "default_page_type")]
|
||||
pub page_type: String,
|
||||
/// 文章中的标题结构
|
||||
#[serde(default)]
|
||||
pub headings: Vec<Heading>,
|
||||
}
|
||||
|
||||
/// 默认页面类型为article
|
||||
fn default_page_type() -> String {
|
||||
"article".to_string()
|
||||
}
|
||||
|
||||
/// 索引类型 - 用于区分不同的索引
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IndexType {
|
||||
/// 只包含基本信息的索引
|
||||
Basic,
|
||||
/// 包含筛选所需的标签和日期索引
|
||||
Filter,
|
||||
/// 包含全文搜索索引
|
||||
Search,
|
||||
/// 完整索引,包含所有内容
|
||||
Full,
|
||||
}
|
||||
|
||||
/// 索引元数据 - 存储索引的基本信息
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct IndexMetadata {
|
||||
/// 索引包含的文章数量
|
||||
pub article_count: usize,
|
||||
/// 索引包含的标签数量
|
||||
pub tag_count: usize,
|
||||
/// 索引创建时间
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// 索引版本
|
||||
pub version: String,
|
||||
/// 索引类型
|
||||
pub index_type: IndexType,
|
||||
/// 索引中的词元总数
|
||||
pub token_count: usize,
|
||||
}
|
||||
|
||||
/// 标题索引项 - 存储标题与内容匹配关系
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct HeadingIndexEntry {
|
||||
/// 标题ID (文章ID:标题索引)
|
||||
pub id: String,
|
||||
/// 标题级别
|
||||
pub level: usize,
|
||||
/// 标题文本
|
||||
pub text: String,
|
||||
/// 标题内容起始位置
|
||||
pub start_position: usize,
|
||||
/// 标题内容结束位置
|
||||
pub end_position: usize,
|
||||
/// 父标题ID (如果有)
|
||||
pub parent_id: Option<String>,
|
||||
}
|
Loading…
Reference in New Issue
Block a user