修改架构,移动端优先。

save
This commit is contained in:
cheney
2026-08-03 18:05:50 +08:00
committed by Cheney
parent 1c07b417ce
commit 08b3655a38
71 changed files with 2524 additions and 290 deletions
+17
View File
@@ -0,0 +1,17 @@
require('dotenv').config();
const mysql = require('mysql2/promise');
(async () => {
const u = new URL(process.env.DATABASE_URL);
const c = await mysql.createConnection({
host: u.hostname, port: Number(u.port),
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
});
// 保留 id 最小的, 删掉其余重名行
for (const t of ['Strategy', 'Indicator']) {
const [r] = await c.query('DELETE FROM ' + t + ' WHERE id NOT IN (SELECT min_id FROM (SELECT MIN(id) AS min_id FROM ' + t + ' GROUP BY name) AS keep)');
console.log('[' + t + '] 删除 ' + r.affectedRows + ' 条重复行');
}
await c.end();
})().catch(e=>{console.error(e);process.exit(1)});
+37
View File
@@ -0,0 +1,37 @@
// 从 public/favicon.svg 派生 Tauri 需要的各尺寸 PNG 图标
// 用法: node scripts/generate-icons.mjs
// 一次性脚本, 生成的图标随仓库提交, 不需要每次都跑
import sharp from 'sharp'
import path from 'node:path'
import fs from 'node:fs'
const SVG = path.join(process.cwd(), 'public', 'favicon.svg')
const OUT = path.join(process.cwd(), 'src-tauri', 'icons')
fs.mkdirSync(OUT, { recursive: true })
const svg = fs.readFileSync(SVG)
const base = sharp(svg)
// Tauri 桌面 + Android 需要的尺寸
const TARGETS = [
{ name: '32x32.png', size: 32 },
{ name: '128x128.png', size: 128 },
{ name: '128x128@2x.png', size: 256 },
{ name: 'icon.png', size: 1024 },
]
for (const t of TARGETS) {
const buf = await base.clone().resize(t.size, t.size).png().toBuffer()
fs.writeFileSync(path.join(OUT, t.name), buf)
console.log('wrote', t.name, t.size + 'x' + t.size)
}
// Windows .ico (单 256x256 frame, 简单够用)
const ico = await sharp(svg).resize(256, 256).png().toBuffer()
fs.writeFileSync(path.join(OUT, 'icon.ico'), ico)
console.log('wrote icon.ico (256x256 PNG-as-ICO)')
// macOS .icns: TaurCli 可用 png2icns 工具, 这里只放占位
// 如果未来要打 macOS DMG, 跑 cargo tauri icon 重新生成
fs.copyFileSync(path.join(OUT, 'icon.png'), path.join(OUT, 'icon.icns'))
console.log('wrote icon.icns (copy of icon.png, run cargo tauri icon to replace)')
+1
View File
File diff suppressed because one or more lines are too long
+164
View File
@@ -0,0 +1,164 @@
/**
* iBoard 基础数据初始化脚本
* 用法: npm run db:seed
* 幂等: 主键/唯一键冲突跳过, Strategy/Indicator 按 name 存在则更新
*/
require('dotenv').config();
const crypto = require('crypto');
const mysql = require('mysql2/promise');
function hashPassword(plain) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(plain, salt, 64).toString('hex');
return 'scrypt$' + salt + '$' + hash;
}
function parseUrl(url) {
const u = new URL(url);
return {
host: u.hostname,
port: Number(u.port) || 3306,
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
};
}
async function insertIgnore(conn, sql, params) {
try {
const [r] = await conn.execute(sql, params);
return r.affectedRows;
} catch (e) {
if (e.code === 'ER_DUP_ENTRY') return 0;
throw e;
}
}
async function upsertByName(conn, table, payload, keyField) {
const fields = Object.keys(payload);
const placeholders = fields.map(() => '?').join(', ');
const updateClause = fields
.filter((f) => f !== keyField)
.map((f) => '`' + f + '` = VALUES(`' + f + '`)')
.join(', ');
const sql = 'INSERT INTO `' + table + '` (' + fields.map((f) => '`' + f + '`').join(', ') + ') VALUES (' + placeholders + ') ON DUPLICATE KEY UPDATE ' + updateClause;
const params = fields.map((f) => payload[f]);
const [r] = await conn.execute(sql, params);
return r.affectedRows;
}
async function main() {
const cfg = parseUrl(process.env.DATABASE_URL);
if (!cfg.host) throw new Error('DATABASE_URL 未配置或解析失败');
const conn = await mysql.createConnection(cfg);
console.log('已连接:', cfg.host + ':' + cfg.port + '/' + cfg.database);
const adminPwd = hashPassword('admin123');
const n1 = await insertIgnore(
conn,
'INSERT INTO `User` (name, email, password, createdAt, updatedAt) VALUES (?, ?, ?, NOW(3), NOW(3))',
['Admin', 'admin@iboard.local', adminPwd],
);
console.log('[User] 新增管理员: ' + n1 + ' 行 (admin@iboard.local / admin123)');
const strategies = [
{ name: 'us-debt-gdp', description: '美国政府债务/GDP分析',
data: { source: 'strategy.js', unit: '万亿美元', rows: [
{ year: '2019', debt: 22.7, gdp: 21.4, debtToGdp: 106.1, interestToGdp: 1.8 },
{ year: '2020', debt: 26.9, gdp: 20.9, debtToGdp: 128.7, interestToGdp: 1.6 },
{ year: '2021', debt: 28.4, gdp: 23.0, debtToGdp: 123.5, interestToGdp: 1.5 },
{ year: '2022', debt: 30.9, gdp: 25.4, debtToGdp: 121.6, interestToGdp: 1.6 },
{ year: '2023', debt: 33.1, gdp: 26.9, debtToGdp: 123.0, interestToGdp: 1.9 },
{ year: '2024', debt: 35.3, gdp: 28.5, debtToGdp: 123.9, interestToGdp: 2.1 },
]},
},
{ name: 'china-debt-gdp', description: '中国政府债务/GDP分析',
data: { source: 'strategy.js', unit: '万亿美元', rows: [
{ year: '2019', debt: 8.8, gdp: 14.3, debtToGdp: 61.5, interestToGdp: 1.9 },
{ year: '2020', debt: 10.3, gdp: 14.7, debtToGdp: 70.1, interestToGdp: 1.8 },
{ year: '2021', debt: 11.7, gdp: 16.1, debtToGdp: 72.7, interestToGdp: 1.7 },
{ year: '2022', debt: 13.1, gdp: 17.9, debtToGdp: 73.2, interestToGdp: 1.6 },
{ year: '2023', debt: 14.5, gdp: 19.4, debtToGdp: 74.7, interestToGdp: 1.6 },
{ year: '2024', debt: 15.9, gdp: 21.0, debtToGdp: 75.7, interestToGdp: 1.5 },
]},
},
];
let n2 = 0;
for (const s of strategies) {
n2 += await upsertByName(conn, 'Strategy',
{ name: s.name, description: s.description, data: JSON.stringify(s.data), updatedAt: new Date() },
'name');
}
console.log('[Strategy] upsert 策略: ' + n2 + ' 行 (1=新增, 2=更新)');
const indicators = [
{ name: 'china-bond-1y', groupName: 'china-bond',
data: { unit: '%', title: '中国国债1年', rows: [
{ month: '2024-01', value: 1.85 }, { month: '2024-02', value: 1.82 },
{ month: '2024-03', value: 1.78 }, { month: '2024-04', value: 1.75 },
{ month: '2024-05', value: 1.72 }, { month: '2024-06', value: 1.68 },
]}},
{ name: 'china-bond-10y', groupName: 'china-bond',
data: { unit: '%', title: '中国国债10年', rows: [
{ month: '2024-01', value: 2.65 }, { month: '2024-02', value: 2.62 },
{ month: '2024-03', value: 2.58 }, { month: '2024-04', value: 2.55 },
{ month: '2024-05', value: 2.52 }, { month: '2024-06', value: 2.48 },
]}},
{ name: 'us-bond-1y', groupName: 'us-bond',
data: { unit: '%', title: '美国国债1年', rows: [
{ month: '2024-01', value: 4.85 }, { month: '2024-02', value: 4.82 },
{ month: '2024-03', value: 4.78 }, { month: '2024-04', value: 4.65 },
{ month: '2024-05', value: 4.55 }, { month: '2024-06', value: 4.45 },
]}},
{ name: 'china-lpr-1y', groupName: 'china-lpr',
data: { unit: '%', title: '中国LPR 1年期', rows: [
{ month: '2024-01', value: 3.45 }, { month: '2024-02', value: 3.45 },
{ month: '2024-03', value: 3.45 }, { month: '2024-04', value: 3.40 },
{ month: '2024-05', value: 3.40 }, { month: '2024-06', value: 3.40 },
]}},
];
let n3 = 0;
for (const i of indicators) {
n3 += await upsertByName(conn, 'Indicator',
{ name: i.name, groupName: i.groupName, data: JSON.stringify(i.data), updatedAt: new Date() },
'name');
}
console.log('[Indicator] upsert 指标: ' + n3 + ' 行 (1=新增, 2=更新)');
const article = {
title: '欢迎使用 iBoard',
slug: 'welcome-to-iboard',
content: 'iBoard 用于收集和整理经济数据,以策略为主体重新组织。\n\n本文为示例条目,正式文章仍由 content/articles/*.md 文件承载。',
date: '2026-08-03',
};
const n4 = await insertIgnore(
conn,
'INSERT INTO `Article` (title, slug, content, date, createdAt, updatedAt) VALUES (?, ?, ?, ?, NOW(3), NOW(3))',
[article.title, article.slug, article.content, article.date],
);
console.log('[Article] 新增示例文章: ' + n4 + ' 行');
const sowRows = [
{ month: '2024-01', inventory: 4067 },
{ month: '2024-02', inventory: 4042 },
{ month: '2024-03', inventory: 3992 },
{ month: '2024-04', inventory: 3986 },
{ month: '2024-05', inventory: 3996 },
{ month: '2024-06', inventory: 4038 },
];
let n5 = 0;
for (const r of sowRows) {
n5 += await insertIgnore(
conn,
'INSERT INTO `Econ_SowInventory` (month, inventory, createdAt, updatedAt) VALUES (?, ?, NOW(3), NOW(3))',
[r.month, r.inventory],
);
}
console.log('[Econ_SowInventory] 新增: ' + n5 + ' 行');
await conn.end();
console.log('\nSeed 完成.');
}
main().catch((e) => { console.error('Seed 失败:', e); process.exit(1); });
+22
View File
@@ -0,0 +1,22 @@
require('dotenv').config();
const mysql = require('mysql2/promise');
(async () => {
const u = new URL(process.env.DATABASE_URL);
const c = await mysql.createConnection({
host: u.hostname, port: Number(u.port),
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
});
const [users] = await c.query('SELECT id,name,email,LEFT(password,20) AS pwd_prefix,createdAt FROM User');
console.log('User:'); console.table(users);
const [sow] = await c.query('SELECT * FROM Econ_SowInventory ORDER BY month');
console.log('Econ_SowInventory:'); console.table(sow);
const [str] = await c.query('SELECT id,name,description,JSON_LENGTH(data,"$.rows") AS row_count FROM Strategy');
console.log('Strategy:'); console.table(str);
const [ind] = await c.query('SELECT id,name,groupName,JSON_LENGTH(data,"$.rows") AS row_count FROM Indicator');
console.log('Indicator:'); console.table(ind);
const [art] = await c.query('SELECT id,title,slug,date FROM Article');
console.log('Article:'); console.table(art);
await c.end();
})().catch(e=>{console.error(e);process.exit(1)});
+31
View File
@@ -0,0 +1,31 @@
import 'dotenv/config'
import { PrismaMariaDb } from '@prisma/adapter-mariadb'
import { PrismaClient } from '../generated/prisma/client.ts'
function parseDatabaseUrl(url) {
const u = new URL(url)
return {
host: u.hostname,
port: Number(u.port) || 3306,
user: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
}
}
const p = new PrismaClient({ adapter: new PrismaMariaDb(parseDatabaseUrl(process.env.DATABASE_URL)) })
try {
const users = await p.user.findMany()
console.log('User count:', users.length, '->', users.map((u) => u.email).join(','))
const strategies = await p.strategy.findMany({ select: { id: true, name: true } })
console.log('Strategy count:', strategies.length, '->', strategies.map((s) => s.name).join(','))
const indicators = await p.indicator.findMany({ select: { id: true, name: true } })
console.log('Indicator count:', indicators.length, '->', indicators.map((i) => i.name).join(','))
const sow = await p.econ_SowInventory.findMany({ orderBy: { month: 'asc' } })
console.log('SowInventory count:', sow.length, '->', sow.map((s) => s.month + '=' + s.inventory).join(','))
} catch (e) {
console.error('ERR:', e.message)
process.exitCode = 1
} finally {
await p.$disconnect()
}
+22
View File
@@ -0,0 +1,22 @@
require('dotenv').config();
const mysql = require('mysql2/promise');
(async () => {
const url = new URL(process.env.DATABASE_URL);
const conn = await mysql.createConnection({
host: url.hostname,
port: Number(url.port),
user: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
database: url.pathname.replace(/^\//, ''),
});
const [tables] = await conn.query('SHOW TABLES');
console.log('TABLES:');
for (const row of tables) console.log(' -', Object.values(row)[0]);
const tableNames = ['User', 'Article', 'Strategy', 'Indicator', 'Econ_SowInventory'];
for (const t of tableNames) {
const [r] = await conn.query('SELECT COUNT(*) AS c FROM ' + t + '');
console.log(t + ': ' + r[0].c + ' rows');
}
await conn.end();
})().catch((e) => { console.error('ERR:', e.message); process.exit(1); });
+15
View File
@@ -0,0 +1,15 @@
const { PrismaClient } = require('../generated/prisma');
(async () => {
const p = new PrismaClient();
const tables = await p.$queryRawUnsafe('SHOW TABLES');
console.log('TABLES:', JSON.stringify(tables, null, 2));
const counts = {
User: await p.user.count(),
Article: await p.article.count(),
Strategy: await p.strategy.count(),
Indicator: await p.indicator.count(),
Econ_SowInventory: await p.econ_SowInventory.count(),
};
console.log('COUNTS:', JSON.stringify(counts, null, 2));
await p.$disconnect();
})().catch((e) => { console.error(e); process.exit(1); });
+22
View File
@@ -0,0 +1,22 @@
import 'dotenv/config';
import { PrismaClient } from '../generated/prisma/client.ts';
const url = process.env.DATABASE_URL;
const p = new PrismaClient({ datasourceUrl: url });
try {
const tables = await p.$queryRawUnsafe('SHOW TABLES');
console.log('TABLES:', JSON.stringify(tables, null, 2));
const counts = {
User: await p.user.count(),
Article: await p.article.count(),
Strategy: await p.strategy.count(),
Indicator: await p.indicator.count(),
Econ_SowInventory: await p.econ_SowInventory.count(),
};
console.log('COUNTS:', JSON.stringify(counts, null, 2));
} catch (e) {
console.error('ERR:', e.message);
process.exitCode = 1;
} finally {
await p.$disconnect();
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# iBoard Android APK 打包
# 在 WSL 内执行: bash scripts/wsl/build-apk.sh [debug|release]
# 默认 debug, release 需要先配签名 (见末尾)
set -euo pipefail
WIN_PROJECT='/mnt/d/workbench/iboard'
cd "$WIN_PROJECT"
VARIANT="${1:-debug}"
echo "构建变体: $VARIANT"
# 环境变量 (setup.sh 已写过, 防御性再写一次)
export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/26.1.10909125}"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/.cargo/bin:$PATH"
# 1. 先确保前端 dist/ 最新
echo "=== 1. 构建前端 ==="
npm run build
# 2. 进 src-tauri 跑 cargo tauri android build
echo "=== 2. 构建 APK ==="
cd src-tauri
if [ "$VARIANT" = "release" ]; then
# release 需要签名, 首次请先生成 keystore:
# keytool -genkey -v -keystore ~/iboard-release.keystore -alias iboard \
# -keyalg RSA -keysize 2048 -validity 10000
# 然后在 ~/.gradle/gradle.properties 配:
# IBOARD_UPLOAD_STORE_FILE=/path/to/iboard-release.keystore
# IBOARD_UPLOAD_KEY_ALIAS=iboard
# IBOARD_UPLOAD_STORE_PASSWORD=...
# IBOARD_UPLOAD_KEY_PASSWORD=...
npx tauri android build --release
else
npx tauri android build --debug
fi
echo
echo "=== 产物位置 ==="
APK_DIR="$WIN_PROJECT/src-tauri/target/$( [ "$VARIANT" = "release" ] && echo release || echo debug )/bundle/android/app/build/outputs/apk/$VARIANT"
ls -lh "$APK_DIR"/*.apk 2>/dev/null || echo "(未找到 APK, 请检查上面日志)"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# 把刚打好的 APK 装到已连接的 adb 设备/模拟器
# 用法: bash scripts/wsl/install-apk.sh [debug|release]
set -euo pipefail
VARIANT="${1:-debug}"
WIN_PROJECT='/mnt/d/workbench/iboard'
export PATH="$HOME/android-sdk/platform-tools:$PATH"
APK_DIR="$WIN_PROJECT/src-tauri/target/$( [ "$VARIANT" = "release" ] && echo release || echo debug )/bundle/android/app/build/outputs/apk/$VARIANT"
APK=$(ls -1 "$APK_DIR"/*.apk | head -1)
echo "安装: $APK"
adb install -r "$APK"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# iBoard Android 端 WSL 首次搭建脚本
# 在 WSL (Ubuntu 22.04+) 内执行: bash scripts/wsl/setup.sh
# 完成后即可运行: bash scripts/wsl/build-apk.sh
set -euo pipefail
# Windows 项目路径 (按需调整)
WIN_PROJECT='/mnt/d/workbench/iboard'
cd "$WIN_PROJECT"
echo "=== 1. 系统依赖 ==="
sudo apt update
sudo apt install -y \
build-essential curl wget file libssl-dev pkg-config \
unzip zip git \
openjdk-17-jdk-headless \
clang libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev \
libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
echo "=== 2. Rust ==="
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
fi
rustc --version
cargo --version
echo "=== 3. Android SDK + NDK ==="
export ANDROID_HOME="$HOME/Android/Sdk"
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$(ls -1 $ANDROID_HOME/ndk 2>/dev/null | sort -V | tail -1 || echo 26.1.10909125)"
mkdir -p "$HOME/android-sdk/cmdline-tools"
cd /tmp
if [ ! -d "$HOME/android-sdk/cmdline-tools/latest" ]; then
echo "下载 Android cmdline-tools..."
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdline-tools.zip
unzip -q cmdline-tools.zip
mv cmdline-tools "$HOME/android-sdk/cmdline-tools/latest"
fi
export PATH="$HOME/android-sdk/cmdline-tools/latest/bin:$PATH"
yes | sdkmanager --licenses >/dev/null
sdkmanager --install "platforms;android-34" "build-tools;34.0.0" "platform-tools" \
"ndk;26.1.10909125" "cmake;3.22.1"
echo "ANDROID_HOME=$ANDROID_HOME"
echo "ANDROID_NDK_HOME=$ANDROID_NDK_HOME"
echo "=== 4. Node 依赖 (与 Windows 共用 node_modules 也行, 这里重新装) ==="
cd "$WIN_PROJECT"
# 跳过 Windows 二进制不兼容的 sharp 等, npm 会自动 rebuild
npm install
echo "=== 5. Tauri Android 工程初始化 (首次执行) ==="
cd "$WIN_PROJECT/src-tauri"
if [ ! -d "gen" ]; then
npx tauri android init
fi
echo
echo "=== 搭建完成 ==="
echo "接下来:"
echo " bash scripts/wsl/build-apk.sh # 打 debug APK"
echo " bash scripts/wsl/build-apk.sh release # 打 release APK (需要签名)"
echo
echo "如要永久生效, 把下面加进 ~/.bashrc:"
echo " export ANDROID_HOME=$ANDROID_HOME"
echo " export ANDROID_NDK_HOME=$ANDROID_NDK_HOME"
echo " export PATH=\$PATH:$HOME/android-sdk/cmdline-tools/latest/bin:$HOME/android-sdk/platform-tools"