重构搜索和筛选,采用rust高效构建二进制数据,wasm高效解析,搜索支持内联建议,优化导航栏样式,解析地图数据采用wasm,地图动态导入优化代码块样式实现解析mermaid

This commit is contained in:
lsy 2025-05-02 23:47:55 +08:00
parent 8656f037bd
commit c59f4a1d24
69 changed files with 18282 additions and 4618 deletions

4
.gitignore vendored
View File

@ -23,4 +23,8 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
# wasm
**/target/*
**/pkg/*
.vercel

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

Binary file not shown.

Binary file not shown.

View 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;

Binary file not shown.

View 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;

Binary file not shown.

View 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;

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -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">

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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() {

View File

@ -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">

View File

@ -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';

View File

@ -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"。

View File

@ -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)限制

View File

@ -1,5 +1,5 @@
---
title: rust
title: 基础语法
date: 2024-10-09T18:49:45Z
tags: []
---

View 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;
```

View File

@ -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

View File

@ -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

View File

@ -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('/');

View File

@ -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>

View File

@ -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
View 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

View File

@ -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>

View File

@ -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">

View File

@ -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}

View 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
};
}
}

File diff suppressed because it is too large Load Diff

537
src/styles/code-blocks.css Normal file
View 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);
}

View File

@ -1,4 +1,6 @@
@import "./table-styles.css";
@import "./code-blocks.css";
@import "./mermaid-themes.css";
/* 增强列表样式 */
.prose ul {

View File

@ -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);
}

View File

@ -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;
}

View 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;
}

View File

@ -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);
}
}
}

View File

@ -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

File diff suppressed because it is too large Load Diff

36
wasm/Cargo.toml Normal file
View 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

View 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" }

View 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(())
}
}

View 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) = &params.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) = &params.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(&params)
.map_err(|e| JsValue::from_str(&e))?;
// 序列化结果
serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("序列化结果失败: {}", e)))
}
}

View 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(),
}
}
}

View 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" }

View 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
View 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
View 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(&region_name);
// 处理几何体
if let Some(geom) = &feature.geometry {
match &geom.value {
Value::Polygon(polygon) => {
self.process_polygon(polygon, &region_name, is_visited, scale,
region_tree, regions, boundary_lines)?;
}
Value::MultiPolygon(multi_polygon) => {
for polygon in multi_polygon {
self.process_polygon(polygon, &region_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
View 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工具链crosswsldocker
### linux
```bash
rustup target add x86_64-unknown-linux-musl
```

25
wasm/search/Cargo.toml Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

159
wasm/search/src/models.rs Normal file
View 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
}

View 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"]

View 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)
}

View 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};

View File

@ -0,0 +1,93 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// 标题结构 - 存储文章中的标题及其层级
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Heading {
/// 标题级别1表示h12表示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>,
}