Compare commits

...

65 Commits

Author SHA1 Message Date
shenmo7192 274e086bb1 !397 支持使用debian脚本构建deb包、新增投稿器(初稿)
Merge pull request !397 from gfdgd xi/Erotica
2026-06-18 16:40:45 +00:00
shenmo7192 341d87cd8d !399 add nixos support and update readme
Merge pull request !399 from SunnyPai/Erotica
2026-06-18 02:45:38 +00:00
gfdgd-xi 337c7b8200 修复因表单错误导致无法正常审核的问题
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-17 17:28:27 +08:00
sunnypai 399b59dae8 fix pkexec lookup for privilege escalation 2026-06-17 13:30:38 +08:00
sunnypai 23b09ca863 update readme.md 2026-06-17 12:59:11 +08:00
sunnypai 601d3f51f4 add nixos support 2026-06-17 12:51:44 +08:00
gfdgd-xi cd3e087cdf 修复投稿器加载多个异常程序截图的问题
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-17 11:21:38 +08:00
gfdgd-xi bd8d070f32 Bump version 5.2.0~alpha1
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-16 22:43:28 +08:00
gfdgd-xi 3d964c9473 使用debian脚本构建deb包
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-16 22:29:05 +08:00
gfdgd-xi 3aa96f27c7 新增投稿器
Signed-off-by: gfdgd_xi <3025613752@qq.com>
2026-06-16 11:53:46 +08:00
shenmo7192 3847463b6e aptss pt_BR fix 2026-06-13 19:31:13 +08:00
shenmo7192 8e7c6bc67d add pt_BR trans 2026-06-13 19:20:21 +08:00
shenmo7192 c3ea2ddf1b !396 !1 调整Lists 区域标题在dark模式下的字体颜色,使其和欢迎语颜色一致,增加下载队列切换展开按钮在dark下的hover效果
Merge pull request !396 from shenmo/Erotica
2026-06-13 11:05:49 +00:00
shenmo7192 21e8a2ca3e !1 调整Lists 区域标题在dark模式下的字体颜色,使其和欢迎语颜色一致,增加下载队列切换展开按钮在dark下的hover效果
Merge pull request !1 from zeqi/Erotica
2026-06-10 12:44:34 +00:00
shenmo7192 e7b90e5727 !395 新增 spark-store 支持处理 apt://包名 格式的链接,转成 spk://search/包名协议来处理
Merge pull request !395 from 球球代码研发助手/implement-33298693-3R1t
2026-06-10 12:13:44 +00:00
gitee-bot 9e8758b5f2 feat(deeplink): add support for apt:// protocol handling
- Register apt protocol handler in Electron main process
- Add x-scheme-handler/apt MIME type to electron-builder config
- Update desktop entry to include apt MIME type support
- apt://pkgname links are now converted to spk://search/pkgname

refs #IJTPFP
2026-06-10 11:50:21 +00:00
shenmo7192 e84c1d86bf !394 新增 spark-store 支持处理 apt://包名 格式的链接,转成 spk://search/包名协议来处理
Merge pull request !394 from 球球代码研发助手/implement-33298693-hy0X
2026-06-10 11:46:22 +00:00
gitee-bot e39525901e feat(deeplink): add support for apt:// protocol conversion to spk://search
Add handling for apt://pkgname format links by converting them to
spk://search/pkgname protocol, allowing spark-store to process
apt package links seamlessly

refs #IJTPFP
2026-06-10 11:36:31 +00:00
zeqi f7eeddf6d9 调整Lists 区域标题在dark模式下的字体颜色,使其和欢迎语颜色一致,增加下载队列切换展开按钮在dark下的hover效果
Signed-off-by: zeqi <a202128502@163.com>
2026-06-06 14:05:54 +00:00
shenmo7192 24d55d0997 refactor: 优化代码结构并新增多标签分类应用加载逻辑
1. 重构下载重试超时列表为多行格式提升可读性
2. 合并环境变量配置行简化代码
3. 新增多标签分类页面的应用加载能力,包括:
   - 添加displayCategories和displayApps计算属性
   - 实现loadTabCategories和loadTabApps加载子分类数据
   - 抽离normalizeAppJson复用应用数据格式化逻辑
   - 优化侧边栏分类切换的应用过滤逻辑
2026-05-30 20:41:59 +08:00
momen 439af8c26f feat(account): polish reviews favorites and account UI 2026-05-29 21:34:42 +08:00
shenmo7192 8e8617218a !391 增加对aosc os的支持
Merge pull request !391 from Melorise/add-support-for-aosc
2026-05-22 15:06:40 +00:00
Melorise 97f49201b7 增加对aosc os的支持 2026-05-20 10:31:49 +08:00
momen abeb511c06 docs(ui): plan client polish fixes 2026-05-20 09:55:37 +08:00
momen 8d80a02316 fix(account): polish sidebar favorites and sync feedback 2026-05-19 23:48:41 +08:00
momen 932e69fca7 fix(account): configure production backend endpoint 2026-05-19 21:19:57 +08:00
momen 87d0cdc036 fix(favorites): keep folder selector above detail modal 2026-05-19 19:29:19 +08:00
momen 1de42a261a feat(reviews): show reviewer avatars 2026-05-19 16:30:41 +08:00
momen d03c8aab61 fix(ui): pin detail modal sidebar 2026-05-19 16:04:47 +08:00
momen ceb861a5b7 docs(ui): plan fixed detail sidebar scroll 2026-05-19 15:59:01 +08:00
momen ad831ce146 docs(ui): specify fixed detail sidebar scroll 2026-05-19 15:57:48 +08:00
momen a341071a3e docs(account): add reviews sync planning docs 2026-05-19 15:09:46 +08:00
momen 0895eb5049 test(reviews): cover server submit failures 2026-05-19 14:49:13 +08:00
momen fd17fc127d fix(reviews): restore modal detail review gating 2026-05-19 12:22:52 +08:00
momen 04b0ca061b merge: account collections implementation 2026-05-19 10:51:17 +08:00
momen deff1c20c4 fix(auth): clarify flarum login failures 2026-05-19 10:50:42 +08:00
momen a8a00d8165 fix(account): gate reviews and stale refreshes 2026-05-19 08:16:57 +08:00
momen 341c740ced test(sync): assert stale apps never upload 2026-05-19 02:20:48 +08:00
momen 839f4017f8 fix(sync): guard stale installed refreshes 2026-05-19 02:16:39 +08:00
momen 34551fce7b fix(sync): isolate installed sync state 2026-05-19 02:03:29 +08:00
momen 753f91e837 fix(sync): resolve restore item edge cases 2026-05-19 01:50:34 +08:00
momen acffb6c5ee feat(sync): add installed app cloud sync 2026-05-19 01:43:28 +08:00
momen ac1f46bd73 fix(account): ignore stale downloaded history 2026-05-19 01:33:34 +08:00
momen bbd9cbccb7 feat(account): add user management view 2026-05-19 01:17:58 +08:00
momen f280039874 fix(account): guard download record user races 2026-05-19 01:10:17 +08:00
momen b839e0770c fix(account): bind pending downloads to user 2026-05-19 01:02:53 +08:00
momen 4b81869b6e fix(account): keep download record pending through retry 2026-05-19 00:54:36 +08:00
momen 4c2225290c fix(account): record downloads after success 2026-05-19 00:44:36 +08:00
momen 78a04fb51f feat(account): record downloads and show reviews 2026-05-19 00:25:57 +08:00
momen 8da044495a fix(favorites): ignore stale account requests 2026-05-19 00:18:01 +08:00
momen 3a4aa7807a fix(favorites): clear account data on logout 2026-05-19 00:07:26 +08:00
momen 3a8baf606c fix(favorites): refresh installed apps across origins 2026-05-18 23:53:44 +08:00
momen 58789ecd1f fix(favorites): honor source priority and installed state 2026-05-18 23:40:28 +08:00
momen e116dcee63 feat(favorites): add cloud favorite management 2026-05-18 23:27:56 +08:00
momen 75df598bc0 fix(detail): normalize review store arch 2026-05-18 23:14:10 +08:00
momen e607e4991b feat(detail): move app details into content view 2026-05-18 23:08:29 +08:00
momen c2e8b9a1b4 fix(account): route forum login through ipc 2026-05-18 22:55:21 +08:00
momen 62081fb0ad fix(account): trim forum login password 2026-05-18 22:39:00 +08:00
momen 63dac217c2 feat(account): add forum login and sidebar account entry 2026-05-18 22:34:14 +08:00
momen c24c88458c fix(account): default backend api base url 2026-05-18 22:21:18 +08:00
momen 881aceb78c feat(account): add client account api foundation 2026-05-18 22:02:39 +08:00
momen 960bababc5 docs(account): add account collections implementation plans 2026-05-18 19:53:16 +08:00
momen 82d097330a docs(account): specify account collections client design 2026-05-18 19:24:21 +08:00
shenmo7192 c877f0551e feat: 新增动态侧边栏配置功能,优化主题色与侧边栏样式
新增SidebarEntry类型定义与侧边栏配置加载逻辑,支持从服务器拉取sidebar-config.json动态配置侧边栏入口
替换原分类侧边栏为可配置样式,新增CategoryBar分类选择组件,更新品牌色为苹果风格蓝色
重构侧边栏状态管理,拆分activeTab与选中分类逻辑,新增侧边栏入口计数统计
添加SIDEBAR_CONFIG.md文档说明配置格式与使用方法,更新测试用例与组件props
2026-05-18 13:25:52 +08:00
shenmo7192 f62665cd73 release: bump version to 5.1.1 and fix dns download issue
add --async-dns=false aria2 parameter to all download jobs to fix potential dns resolution failures during package download
2026-05-16 02:35:29 +08:00
104 changed files with 21906 additions and 267 deletions
+1
View File
@@ -1,3 +1,4 @@
VITE_APM_STORE_LOCAL_MODE=true
VITE_APM_STORE_BASE_URL=/local_amd64-store
VITE_APM_STORE_STATS_BASE_URL=/local_stats
VITE_SPARK_BACKEND_BASE_URL=http://127.0.0.1:8000
+1
View File
@@ -1,2 +1,3 @@
VITE_APM_STORE_BASE_URL=https://erotica.spark-app.store
VITE_APM_STORE_STATS_BASE_URL=https://feedback.spark-app.store
VITE_SPARK_BACKEND_BASE_URL=https://account.spark-app.store
+12
View File
@@ -14,6 +14,17 @@ dist-electron
release
*.local
# Nix build outputs
/result
/result-*
# Local secrets and databases
.env
.env.*.local
*.sqlite
*.sqlite3
*.db
# Test coverage
coverage
.nyc_output
@@ -40,3 +51,4 @@ yarn.lock
test-results.json
.worktrees/
.superpowers/
+27
View File
@@ -0,0 +1,27 @@
.DEFAULT_GOAL := build
clean:
rm -rf release/
build:
npm run build:deb
install:
mkdir -p $(DESTDIR)/opt/spark-store/bin/
mkdir -p $(DESTDIR)/opt/spark-store/extras/
mkdir -p $(DESTDIR)/opt/durapps/spark-store/bin/
mkdir -p $(DESTDIR)/usr/share/icons/
mkdir -p $(DESTDIR)/usr/lib/
mkdir -p $(DESTDIR)/usr/bin/
cp -rv release/*/linux-unpacked/* $(DESTDIR)/opt/spark-store/bin/
cp -rv release/*/linux-unpacked/extras/* $(DESTDIR)/opt/spark-store/extras/
cp -rv tool/* $(DESTDIR)/opt/durapps/spark-store/bin/
cp -rv pkg/usr/share/fish/ $(DESTDIR)/usr/share/
cp -rv icons/hicolor/ $(DESTDIR)/usr/share/icons/
cp -rv pkg/usr/share/icons/hicolor/ $(DESTDIR)/usr/share/icons/
cp -rv pkg/usr/lib/systemd $(DESTDIR)/usr/lib/
cp -rv pkg/usr/share/applications/ $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/polkit-1 $(DESTDIR)/usr/share/
cp -rv pkg/usr/share/aptss $(DESTDIR)/usr/share/
cp -rv tool/spark-store.asc $(DESTDIR)/opt/durapps/spark-store/bin/
ln -s ../../../spark-store/extras/spark-store $(DESTDIR)/opt/durapps/spark-store/bin/spark-store
+1 -1
View File
@@ -18,7 +18,7 @@ Linux 应用的数量相对有限,Wine 软件的可获取性也颇为困难。
**当前支持的 Linux 发行版包括(但不限于):**
- **amd64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / Fedora / deepin / UOS / 银河麒麟
- **amd64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / Fedora / deepin / UOS / 银河麒麟 / NixOS
- **arm64 架构:** Debian 10+ / Ubuntu 22.04+ / Arch Linux / deepin / UOS / 银河麒麟
- **loong64 架构:** deepin 23/25
+84
View File
@@ -0,0 +1,84 @@
# 侧边栏入口配置 (Sidebar Config)
星火应用商店支持通过服务器上的 JSON 文件动态配置左侧侧边栏的入口项。
## 配置文件位置
`sidebar-config.json` 放置在服务器应用仓库的架构目录下:
```
# Spark 仓库
{baseUrl}/{arch}-store/sidebar-config.json
# APM 仓库
{baseUrl}/{arch}-apm/sidebar-config.json
```
例如:
- `https://example.com/amd64-store/sidebar-config.json`
- `https://example.com/arm64-store/sidebar-config.json`
## JSON 格式
每个入口项为一个对象,包含以下字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | string | ✅ | 唯一标识符,对应分类名或自定义 ID |
| `name` | string | ✅ | 侧边栏显示的入口名称 |
| `icon` | string | ❌ | FontAwesome 图标类名,如 `fas fa-gamepad` |
| `type` | string | ❌ | 入口类型:`category`(分类筛选)、`search`(搜索关键词)、`link`(外部链接)。默认为 `category` |
| `value` | string | ❌ | 与 `type` 配合使用的值。`category` 类型为分类名,`search` 类型为搜索关键词。默认为 `id` 的值 |
## 示例配置
```json
[
{
"id": "games",
"name": "游戏专区",
"icon": "fas fa-gamepad",
"type": "category",
"value": "games"
},
{
"id": "devtools",
"name": "开发工具",
"icon": "fas fa-code",
"type": "category",
"value": "development"
},
{
"id": "office",
"name": "办公学习",
"icon": "fas fa-book",
"type": "category",
"value": "office"
},
{
"id": "ai-search",
"name": "AI 应用",
"icon": "fas fa-robot",
"type": "search",
"value": "AI"
}
]
```
## 入口类型说明
### `category` 类型
点击后在"全部应用"页面按指定分类筛选应用。`value` 字段对应 `categories.json` 中的分类键名。
### `search` 类型
点击后自动使用 `value` 字段的值进行搜索。适用于快速入口,如"AI 应用"、"微信"等热门关键词。
### `link` 类型(预留)
用于跳转到外部链接或内部页面。后续版本支持。
## 注意事项
- 如果两个仓库(Spark 和 APM)都存在 `sidebar-config.json`,相同的 `id` 会自动去重合并
- 配置文件不存在时,侧边栏不会显示额外的入口项,不影响正常使用
- 入口项显示在"首页推荐"和"全部应用"之间,以分隔线区分
- 每个入口项会显示对应分类或搜索下的应用数量
+5
View File
@@ -0,0 +1,5 @@
spark-store (5.2.0~alpha1) UNRELEASED; urgency=medium
* Initial release. (Closes: #nnnn) <nnnn is the bug number of your ITP>
-- shenmo <shenmo@spark-app.store> Tue, 16 Jun 2026 21:45:35 +0800
+40
View File
@@ -0,0 +1,40 @@
Source: spark-store
Section: utils
Priority: optional
Maintainer: shenmo <shenmo@spark-app.store>
Rules-Requires-Root: no
Build-Depends:
debhelper-compat (= 13),
make,
npm,
Standards-Version: 4.7.2
Homepage: https://www.spark-app.store/
Package: spark-store
Architecture: any
Provides: spark-store-console-in-container
Depends:
${shlibs:Depends},
${misc:Depends},
libgtk-3-0,
libnotify4,
libnss3,
libxss1,
libxtst6,
xdg-utils,
libatspi2.0-0,
libuuid1,
libsecret-1-0,
xdg-utils,
shared-mime-info,
aria2,
gnupg,
zenity,
policykit-1 | pkexec,
libnotify-bin,
desktop-file-utils,
lsb-release,
systemd,
curl
Description: Spark Store
A community powered app store, powered by APM.
Vendored Executable
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
case "$1" in
configure)
case `arch` in
x86_64)
echo "Enabling i386 arch..."
dpkg --add-architecture i386
;;
aarch64)
echo "Will not enable armhf since 4271"
;;
loongarch64)
echo "Nope we DO NOT WANT ABI1 now"
dpkg --remove-architecture loongarch64
;;
*)
echo "Unknown architecture, skip enable 32-bit arch"
;;
esac
mkdir -p /var/lib/aptss/lists
# Remove the sources.list file
rm -f /etc/apt/sources.list.d/sparkstore.list
rm -f /opt/durapps/spark-store/bin/apt-fast-conf/sources.list.d/sparkstore.list
# Check if /usr/local/bin existed
mkdir -p /usr/local/bin
## I hate /usr/local/bin. We will abandon them later
# Create symbol links for binary files
ln -s -f /opt/durapps/spark-store/bin/spark-store /usr/local/bin/spark-store
ln -s -f /opt/durapps/spark-store/bin/ssinstall /usr/local/bin/ssinstall
ln -s -f /opt/durapps/spark-store/bin/ssaudit /usr/local/bin/ssaudit
ln -s -f /opt/durapps/spark-store/bin/ssinstall /usr/bin/ssinstall
ln -s -f /opt/durapps/spark-store/bin/ssaudit /usr/bin/ssaudit
ln -s -f /opt/durapps/spark-store/bin/spark-dstore-patch /usr/local/bin/spark-dstore-patch
ln -s -f /opt/durapps/spark-store/bin/spark-dstore-patch /usr/bin/spark-dstore-patch
ln -s -f /opt/durapps/spark-store/bin/aptss /usr/local/bin/ss-apt-fast
ln -s -f /opt/durapps/spark-store/bin/aptss /usr/bin/aptss
# Install key
mkdir -p /tmp/spark-store-install/
cp -f /opt/durapps/spark-store/bin/spark-store.asc /tmp/spark-store-install/spark-store.asc
gpg --dearmor /tmp/spark-store-install/spark-store.asc
cp -f /tmp/spark-store-install/spark-store.asc.gpg /etc/apt/trusted.gpg.d/spark-store.gpg
# Start upgrade detect service
systemctl daemon-reload
systemctl enable spark-update-notifier
systemctl start spark-update-notifier
# Update certain caches
cp -fv /opt/spark-store/extras/store.spark-app.spark-store.policy /usr/share/polkit-1/actions/store.spark-app.spark-store.policy
xdg-mime default spark-store.desktop x-scheme-handler/spk
update-mime-database /usr/share/mime || true
# Send email for statistics
#/tmp/spark-store-install/feedback.sh
# Remove temp dir
rm -rf /tmp/spark-store-install
;;
triggered)
spark-dstore-patch
;;
esac
Vendored Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
rm -fv /usr/share/polkit-1/actions/store.spark-app.spark-store.policy
# Update certain caches
update-icon-caches /usr/share/icons/hicolor || true
update-desktop-database /usr/share/applications || true
update-mime-database /usr/share/mime || true
Vendored Executable
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
#检测网络链接畅通
function network-check()
{
#超时时间
local timeout=15
#目标网站
local target=www.baidu.com
#获取响应状态码
local ret_code=`curl -I -s --connect-timeout ${timeout} ${target} -w %{http_code} | tail -n1`
if [ "x$ret_code" = "x200" ]; then
echo "Network Checked successful ! Continue..."
echo "网络通畅,继续安装"
else
#网络不畅通
echo "Network failed ! Cancel the installation"
echo "网络不畅,终止安装"
exit -1
fi
}
#network-check
echo "不再检测网络"
Vendored Executable
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
function notify-send()
{
# Detect the user using such display
local user=$(who | awk '{print $1}' | head -n 1)
# Detect the id of the user
local uid=$(id -u $user)
sudo -u $user DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus notify-send "$@"
}
if [ "$1" = "remove" -o "$1" = "purge" ] ; then
echo "$1"
echo "卸载操作,进行配置清理"
# Remove residual symbol links
unlink /usr/local/bin/spark-store
unlink /usr/local/bin/ssinstall
unlink /usr/local/bin/ssaudit
unlink /usr/bin/ssinstall
unlink /usr/bin/ssaudit
unlink /usr/local/bin/spark-dstore-patch
unlink /usr/bin/spark-dstore-patch
unlink /usr/local/bin/ss-apt-fast
unlink /usr/bin/aptss
rm -rf /etc/aptss/
rm -rf /var/lib/aptss/
# Remove residual symbol links to stop upgrade detect
rm -f /etc/xdg/autostart/spark-update-notifier.desktop
# Remove config files
for username in `ls /home`
do
echo /home/$username
if [ -d /home/$username/.config/spark-union/spark-store ]
then
rm -rf /home/$username/.config/spark-union/spark-store
fi
done
# Shutdown services
systemctl stop spark-update-notifier
# Stop update detect service
systemctl disable spark-update-notifier
# Remove gpg key file
rm -f /etc/apt/trusted.gpg.d/spark-store.gpg
apt-key del '9D9A A859 F750 24B1 A1EC E16E 0E41 D354 A29A 440C' || true
else
if [ ! -z "`pidof spark-store`" ] ; then
echo "关闭已有 spark-store.."
notify-send "正在升级星火商店" "请在升级结束后重启星火商店" -i spark-store
killall spark-store
fi
fi
Vendored Executable
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/make -f
# See debhelper(7) (uncomment to enable).
# Output every command that modifies files on the build system.
#export DH_VERBOSE = 1
# See FEATURE AREAS in dpkg-buildflags(1).
#export DEB_BUILD_MAINT_OPTIONS = hardening=+all
# See ENVIRONMENT in dpkg-buildflags(1).
# Package maintainers to append CFLAGS.
#export DEB_CFLAGS_MAINT_APPEND = -Wall -pedantic
# Package maintainers to append LDFLAGS.
#export DEB_LDFLAGS_MAINT_APPEND = -Wl,--as-needed
%:
dh $@
override_dh_dwz:
true
override_dh_strip:
true
override_dh_shlibdeps:
true
# dh_make generated override targets.
# This is an example for Cmake (see <https://bugs.debian.org/641051>).
#override_dh_auto_configure:
# dh_auto_configure -- \
# -DCMAKE_LIBRARY_PATH=$(DEB_HOST_MULTIARCH)
+1
View File
@@ -0,0 +1 @@
3.0 (quilt)
Vendored Executable
+2
View File
@@ -0,0 +1,2 @@
interest-noawait /opt/apps
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
# App Detail Fixed Sidebar Scroll Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Keep the app detail modal's left action/meta column fixed on desktop while the right detail/review column scrolls independently.
**Architecture:** Reuse the existing `AppDetailModal.vue` popup and change only its internal layout classes/markers. The modal panel becomes a bounded, non-scrolling shell on desktop, while the right column becomes the desktop scroll container; mobile keeps the single-column scroll behavior.
**Tech Stack:** Vue 3 SFC, Tailwind CSS utilities, Vitest, Testing Library Vue.
---
## File Structure
- Modify: `src/components/AppDetailModal.vue` - adjust modal shell, left column, right scroll column, and scroll reset target.
- Modify: `src/__tests__/unit/AppDetailModal.test.ts` - assert modal shell and scroll column contract.
## Task 1: Add Layout Contract Test
**Files:**
- Modify: `src/__tests__/unit/AppDetailModal.test.ts`
- [ ] **Step 1: Add assertions to the popup modal test**
In `src/__tests__/unit/AppDetailModal.test.ts`, update `renders detail content inside a popup-style modal overlay` so the body after the `.modal-panel` assertion is:
```ts
const panel = overlay?.querySelector(".modal-panel");
expect(panel).toBeTruthy();
expect(panel?.className).toContain("overflow-hidden");
expect(panel?.className).toContain("lg:max-h-[85vh]");
expect(panel?.querySelector('[data-testid="detail-fixed-sidebar"]')).toBeTruthy();
expect(panel?.querySelector('[data-testid="detail-scroll-content"]')).toBeTruthy();
expect(
panel?.querySelector('[data-testid="detail-scroll-content"]')?.className,
).toContain("lg:overflow-y-auto");
```
- [ ] **Step 2: Run test to verify failure**
Run: `npm run test -- --run src/__tests__/unit/AppDetailModal.test.ts`
Expected: FAIL because `data-testid="detail-fixed-sidebar"`, `data-testid="detail-scroll-content"`, and the new modal classes are not present.
## Task 2: Implement Fixed Sidebar Layout
**Files:**
- Modify: `src/components/AppDetailModal.vue`
- Modify: `src/__tests__/unit/AppDetailModal.test.ts`
- [ ] **Step 1: Update modal shell classes**
In `src/components/AppDetailModal.vue`, change the `.modal-panel` class from:
```vue
class="modal-panel relative w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
```
to:
```vue
class="modal-panel relative flex w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900 lg:max-h-[85vh] lg:overflow-hidden lg:pb-0"
```
- [ ] **Step 2: Move the return button into the fixed sidebar**
In `src/components/AppDetailModal.vue`, replace the top-level return button and main layout start with:
```vue
<!-- 主布局左侧信息 + 右侧内容 -->
<div class="flex w-full flex-col gap-6 lg:min-h-0 lg:flex-row">
<!-- 左侧图标版本来源按钮元信息 -->
<div
data-testid="detail-fixed-sidebar"
class="w-full flex-shrink-0 space-y-5 lg:w-72 lg:self-start lg:py-4"
>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-slate-200/70 bg-white/90 px-4 py-2 text-sm font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800/90 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200"
@click="closeModal"
aria-label="返回"
>
<i class="fas fa-arrow-left"></i>
<span>返回</span>
</button>
```
Remove the old sticky top-level return button and the old left column opening:
```vue
<!-- 返回按钮 - sticky定位在模态框内部左上角滚动时始终可见 -->
<button ...>...</button>
<!-- 主布局左侧信息 + 右侧内容 -->
<div class="flex flex-col lg:flex-row gap-6">
<!-- 左侧图标版本来源按钮元信息 -->
<div class="w-full lg:w-72 flex-shrink-0 space-y-5">
```
- [ ] **Step 3: Make the right column the desktop scroll container**
In `src/components/AppDetailModal.vue`, change the right column opening from:
```vue
<div class="flex-1 min-w-0 space-y-5">
```
to:
```vue
<div
data-testid="detail-scroll-content"
class="min-w-0 flex-1 space-y-5 lg:max-h-[85vh] lg:overflow-y-auto lg:overscroll-contain lg:py-4 lg:pr-2"
>
```
- [ ] **Step 4: Update scroll reset target if needed**
In `src/App.vue`, keep the existing modal scroll reset selector if it still points at `.modal-panel`. If the right column needs reset instead, change the query to:
```ts
const modal = document.querySelector(
'[data-app-modal="detail"] [data-testid="detail-scroll-content"]',
);
```
Use `modal.scrollTop = 0` as it does today.
- [ ] **Step 5: Run focused test**
Run: `npm run test -- --run src/__tests__/unit/AppDetailModal.test.ts`
Expected: PASS.
- [ ] **Step 6: Run build verification**
Run: `npm run build:vite`
Expected: PASS.
- [ ] **Step 7: Commit implementation**
Run:
```bash
git add src/components/AppDetailModal.vue src/App.vue src/__tests__/unit/AppDetailModal.test.ts
git commit -m "fix(ui): pin detail modal sidebar"
```
Expected: commit succeeds.
@@ -0,0 +1,141 @@
# Review Avatar Display Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Show cached backend user avatar URLs beside comments in the app review list.
**Architecture:** Use the existing `AppReview.userAvatarUrl` field returned by the backend. `ReviewsPanel.vue` renders an avatar image when present and a stable fallback when missing or failed; tests cover the rendered avatar contract.
**Tech Stack:** Vue 3 SFC, Tailwind CSS utilities, Vitest, Testing Library Vue.
---
## File Structure
- Modify: `src/components/ReviewsPanel.vue` - render reviewer avatar/fallback in each review card and handle broken avatar images.
- Modify: `src/__tests__/unit/ReviewsPanel.test.ts` - add test coverage for avatar display.
## Task 1: Add Avatar Rendering Test
**Files:**
- Modify: `src/__tests__/unit/ReviewsPanel.test.ts`
- [ ] **Step 1: Add review with avatar test**
Append this test inside `describe("ReviewsPanel", () => { ... })` before the final closing brace:
```ts
it("shows reviewer avatars when available", async () => {
vi.mocked(fetchRatingSummary).mockResolvedValue({
averageRating: 5,
reviewCount: 1,
starCounts: { 5: 1 },
});
vi.mocked(fetchReviews).mockResolvedValue([
{
id: 3,
rating: 5,
content: "头像正常显示",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-19T00:00:00Z",
updatedAt: "2026-05-19T00:00:00Z",
userDisplayName: "Avatar User",
userAvatarUrl: "https://bbs.spark-app.store/avatar.png",
},
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const avatar = await screen.findByAltText("Avatar User 的头像");
expect(avatar).toHaveAttribute("src", "https://bbs.spark-app.store/avatar.png");
});
```
- [ ] **Step 2: Run test to verify failure**
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
Expected: FAIL because no avatar image with alt text is rendered.
## Task 2: Render Review Avatars
**Files:**
- Modify: `src/components/ReviewsPanel.vue`
- Modify: `src/__tests__/unit/ReviewsPanel.test.ts`
- [ ] **Step 1: Update review article layout**
In `src/components/ReviewsPanel.vue`, replace the review `<article>` body with this structure:
```vue
<div class="flex gap-3">
<img
v-if="review.userAvatarUrl"
:src="review.userAvatarUrl"
:alt="`${review.userDisplayName || '星火用户'} 的头像`"
class="h-9 w-9 flex-shrink-0 rounded-full bg-slate-100 object-cover dark:bg-slate-800"
loading="lazy"
referrerpolicy="no-referrer"
@error="hideAvatar"
/>
<div
v-else
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
aria-hidden="true"
>
{{ review.userDisplayName?.slice(0, 1) || "星" }}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-3">
<strong class="truncate text-slate-700 dark:text-slate-200">
{{ review.userDisplayName || "星火用户" }}
</strong>
<span class="flex-shrink-0 text-xs text-slate-400">{{ review.rating }} </span>
</div>
<p class="mt-2 whitespace-pre-wrap text-slate-600 dark:text-slate-300">
{{ review.content || "暂无评论内容" }}
</p>
</div>
</div>
```
- [ ] **Step 2: Add avatar error handler**
In the `<script setup>` block of `src/components/ReviewsPanel.vue`, add:
```ts
const hideAvatar = (event: Event) => {
(event.target as HTMLElement).style.display = "none";
};
```
- [ ] **Step 3: Run focused test**
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
Expected: PASS.
- [ ] **Step 4: Run build verification**
Run: `npm run build:vite`
Expected: PASS.
- [ ] **Step 5: Commit and push**
Run:
```bash
git add docs/superpowers/plans/2026-05-19-review-avatar-display.md src/components/ReviewsPanel.vue src/__tests__/unit/ReviewsPanel.test.ts
git commit -m "feat(reviews): show reviewer avatars"
git push origin Erotica
```
Expected: commit and push succeed.
@@ -0,0 +1,284 @@
# Client UI Polish Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix the client-side account, favorites, reviews, sync, restore, and shell UI issues reported in QA.
**Architecture:** Preserve existing Vue component boundaries. Add thin wrappers/helpers for modal account management, custom titlebar, review star controls, and sync restore candidate resolution. Backend-dependent review actions are client-side affordances only until backend endpoints exist.
**Tech Stack:** Vue 3 Composition API, TypeScript strict mode, Electron IPC, Tailwind CSS, Vitest, Testing Library Vue.
---
## File Structure
- Create `src/components/UserManagementModal.vue` to present `UserManagementView` as a global overlay.
- Create `src/components/WindowTitleBar.vue` for frameless titlebar UI and window controls.
- Modify `src/App.vue` to mount the new account modal, pass sync feedback to installed apps modal, pass favorite status to detail modal, and use restore candidate helper.
- Modify `src/components/AppSidebar.vue` and `src/components/AccountQuickMenu.vue` for overflow-safe labels and menu closing coverage.
- Modify `src/components/UserManagementView.vue` for cover-image rendering and modal-friendly layout.
- Modify `src/components/InstalledAppsModal.vue` to show sync feedback locally.
- Modify `src/components/FavoriteFolderSelector.vue` for normalized default-folder de-dupe and favorited-state text/actions.
- Modify `src/components/FavoriteFolderManager.vue` so the entire row content opens detail while checkbox stays isolated.
- Modify `src/components/AppDetailModal.vue` and `src/components/AppDetailPage.vue` to show `已收藏` state.
- Modify `src/components/ReviewsPanel.vue` for star rating, filters, user detail affordance, and disabled backend-dependent actions.
- Modify `src/modules/appListSync.ts` for `resolveCloudInstallCandidate()`.
- Modify `electron/main/index.ts`, `electron/preload/index.ts`, and `src/vite-env.d.ts` for frameless window controls.
- Add/update unit tests under `src/__tests__/unit/`.
---
### Task 1: Account Menu And User Management Modal
**Files:**
- Create: `src/components/UserManagementModal.vue`
- Modify: `src/App.vue`
- Modify: `src/components/AppSidebar.vue`
- Modify: `src/components/AccountQuickMenu.vue`
- Modify: `src/components/UserManagementView.vue`
- Modify: `src/global/typedefinition.ts`
- Modify: `src/modules/backendApi.ts`
- Modify: `src/global/authState.ts`
- Test: `src/__tests__/unit/AppSidebar.account.test.ts`
- Test: `src/__tests__/unit/UserManagementView.test.ts`
- Test: `src/__tests__/unit/App.account-placeholders.test.ts`
- Test: `src/__tests__/unit/accountTypes.test.ts`
- Test: `src/__tests__/unit/authState.test.ts`
- [ ] **Step 1: Write failing sidebar tests**
Add tests to `src/__tests__/unit/AppSidebar.account.test.ts` that verify each quick-menu action closes the menu, and that a user with empty `displayName` and long `username` still gets truncate/min-width classes.
- [ ] **Step 2: Run sidebar tests red**
Run: `npm run test -- --run src/__tests__/unit/AppSidebar.account.test.ts`
Expected: FAIL for missing fallback username coverage or quick-menu item truncation coverage.
- [ ] **Step 3: Implement sidebar overflow and close safety**
Update account label wrappers and quick-menu item labels with `min-w-0`, `truncate`, and explicit close-before-emit behavior already used by the parent.
- [ ] **Step 4: Write failing user management modal tests**
Update `src/__tests__/unit/App.account-placeholders.test.ts` so opening “用户管理” asserts a `role="dialog"` overlay exists and the main app grid/favorites frame is not replaced by `currentView === 'account'`.
- [ ] **Step 5: Write failing cover field tests**
Update `src/__tests__/unit/accountTypes.test.ts`, `authState.test.ts`, and `UserManagementView.test.ts` for optional `coverUrl` on `SparkUser` and visible cover rendering.
- [ ] **Step 6: Implement modal and cover rendering**
Add `UserManagementModal.vue`, add `showUserManagementModal` state in `App.vue`, keep `currentView` unchanged when opening user management, and pass through `close`, `open-forum`, `edit-profile`, `toggle-sync`, `sync-now`, and `refresh-downloads` events.
- [ ] **Step 7: Run account tests green**
Run: `npm run test -- --run src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/UserManagementView.test.ts src/__tests__/unit/App.account-placeholders.test.ts src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts`
Expected: PASS.
---
### Task 2: Favorites State And Interaction Polish
**Files:**
- Modify: `src/App.vue`
- Modify: `src/components/FavoriteFolderSelector.vue`
- Modify: `src/components/FavoriteFolderManager.vue`
- Modify: `src/components/AppDetailModal.vue`
- Modify: `src/components/AppDetailPage.vue`
- Modify: `src/modules/backendApi.ts`
- Test: `src/__tests__/unit/FavoriteFolderSelector.test.ts`
- Test: `src/__tests__/unit/FavoriteFolderManager.test.ts`
- Test: `src/__tests__/unit/AppDetailModal.test.ts`
- Test: `src/__tests__/unit/App.account-placeholders.test.ts`
- [ ] **Step 1: Write failing selector de-dupe tests**
Add a test where backend returns folder name ` 默认收藏夹 ` and assert only one default folder button appears.
- [ ] **Step 2: Run selector test red**
Run: `npm run test -- --run src/__tests__/unit/FavoriteFolderSelector.test.ts`
Expected: FAIL because current de-dupe compares exact name only.
- [ ] **Step 3: Normalize default folder names**
Trim folder names before default-folder comparison.
- [ ] **Step 4: Write failing detail favorite-state tests**
Add tests asserting a favorited app renders `已收藏` in `AppDetailModal` and emits the favorite action when clicked.
- [ ] **Step 5: Implement favorite state props**
Add `favorited`/`favoriteFolderName` props to detail components and compute current favorite metadata in `App.vue` from loaded folders/items.
- [ ] **Step 6: Write failing row click test**
Add a test that clicking the favorite row text/image area opens detail while clicking the checkbox only selects.
- [ ] **Step 7: Implement row-click behavior**
Make the favorite row content area larger/clickable and keep checkbox event isolated.
- [ ] **Step 8: Run favorites tests green**
Run: `npm run test -- --run src/__tests__/unit/FavoriteFolderSelector.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts src/__tests__/unit/AppDetailModal.test.ts src/__tests__/unit/App.account-placeholders.test.ts`
Expected: PASS.
---
### Task 3: Review Panel Client UX
**Files:**
- Modify: `src/components/ReviewsPanel.vue`
- Modify: `src/global/typedefinition.ts`
- Modify: `src/modules/backendApi.ts`
- Test: `src/__tests__/unit/ReviewsPanel.test.ts`
- [ ] **Step 1: Write failing star rating test**
Add a test that clicks the “3 星” star button and verifies `submitReview` receives `rating: 3`.
- [ ] **Step 2: Write failing filter test**
Add reviews with different `packageArch` and `distro`; assert selecting an architecture or OS filter hides non-matching reviews.
- [ ] **Step 3: Write failing review action/user tests**
Add a test that reviewer avatar/name are buttons emitting/showing user detail affordance, and that like/reply/delete controls render with delete disabled unless local permission allows it.
- [ ] **Step 4: Run review tests red**
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
Expected: FAIL because current UI uses select rating and lacks filters/actions.
- [ ] **Step 5: Implement star rating and filters**
Replace the native rating select with five star buttons and add local filter controls for package architecture and distro.
- [ ] **Step 6: Implement client-only review actions**
Render like/reply/delete buttons. Like/reply show a local “后端接口接入后可用” message. Delete is visible only for author/admin-compatible client data; otherwise omit or disable it.
- [ ] **Step 7: Run review tests green**
Run: `npm run test -- --run src/__tests__/unit/ReviewsPanel.test.ts`
Expected: PASS.
---
### Task 4: Sync Feedback And Cross-Origin Restore
**Files:**
- Modify: `src/App.vue`
- Modify: `src/components/InstalledAppsModal.vue`
- Modify: `src/modules/appListSync.ts`
- Test: `src/__tests__/unit/appListSync.test.ts`
- Test: `src/__tests__/unit/App.account-placeholders.test.ts`
- [ ] **Step 1: Write failing restore helper tests**
Add `resolveCloudInstallCandidate()` tests for exact origin/category match, same package fallback across origin, and no candidate.
- [ ] **Step 2: Run sync helper tests red**
Run: `npm run test -- --run src/__tests__/unit/appListSync.test.ts`
Expected: FAIL because helper is missing.
- [ ] **Step 3: Implement restore helper**
Export `resolveCloudInstallCandidate(item, apps)` from `appListSync.ts` and use it in `App.vue` `installCloudItems()`.
- [ ] **Step 4: Write failing sync feedback test**
Update `App.account-placeholders.test.ts` or `InstalledAppsModal.test.ts` to assert clicking sync in the installed-apps modal shows `同步完成` or an error message in that modal.
- [ ] **Step 5: Implement modal sync feedback prop**
Add `syncMessage` prop to `InstalledAppsModal.vue` and pass `syncStatusMessage` from `App.vue`.
- [ ] **Step 6: Run sync tests green**
Run: `npm run test -- --run src/__tests__/unit/appListSync.test.ts src/__tests__/unit/App.account-placeholders.test.ts src/__tests__/unit/InstalledAppsModal.test.ts`
Expected: PASS.
---
### Task 5: Frameless Window And Shell Polish
**Files:**
- Create: `src/components/WindowTitleBar.vue`
- Modify: `src/App.vue`
- Modify: `electron/main/index.ts`
- Modify: `electron/preload/index.ts`
- Modify: `src/vite-env.d.ts`
- Test: add or update appropriate unit tests for titlebar component and source config.
- [ ] **Step 1: Write failing titlebar component test**
Create a unit test that renders `WindowTitleBar.vue`, clicks minimize/maximize/close, and verifies IPC messages are sent.
- [ ] **Step 2: Write failing Electron config test or static check**
Add a lightweight test/static assertion that `BrowserWindow` is created with `frame: false`.
- [ ] **Step 3: Implement IPC handlers and titlebar**
Set `frame: false` in `BrowserWindow`; add IPC listeners for window minimize, toggle maximize, and close using existing close guard; add `WindowTitleBar.vue` with drag/no-drag regions.
- [ ] **Step 4: Mount titlebar in App.vue**
Place the titlebar at the top of the root layout and adjust sticky header offsets as needed.
- [ ] **Step 5: Verify category capsule color**
Run existing `CategoryBar.test.ts`; no code change needed if it already asserts `#2b7fff`.
Run: `npm run test -- --run src/__tests__/unit/CategoryBar.test.ts`
Expected: PASS.
---
### Task 6: Final Verification
**Files:**
- All files touched by previous tasks.
- [ ] **Step 1: Run targeted unit tests**
Run the union of tests touched by this plan:
```bash
npm run test -- --run src/__tests__/unit/AppSidebar.account.test.ts src/__tests__/unit/UserManagementView.test.ts src/__tests__/unit/App.account-placeholders.test.ts src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts src/__tests__/unit/FavoriteFolderSelector.test.ts src/__tests__/unit/FavoriteFolderManager.test.ts src/__tests__/unit/AppDetailModal.test.ts src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/appListSync.test.ts src/__tests__/unit/InstalledAppsModal.test.ts src/__tests__/unit/CategoryBar.test.ts
```
Expected: PASS.
- [ ] **Step 2: Run production build**
Run: `npm run build:vite`
Expected: PASS.
- [ ] **Step 3: Check diff whitespace**
Run: `git diff --check`
Expected: no output.
- [ ] **Step 4: Commit client changes**
Commit message: `fix(ui): polish account favorites reviews and shell`
- [ ] **Step 5: Push feature branch**
Push `fix/client-ui-account-favorites` for review or merge.
@@ -0,0 +1,315 @@
# Spark Account Collections Client Design
## Goal
Extend the Spark Store account experience across the backend and Electron/Vue client so users can log in or register through the forum identity flow, see account management data, use comments and favorites when logged in, sync store-recognized installed apps, and batch install apps from cloud favorites without breaking anonymous browsing, installation, removal, or update features.
## Scope
### Included
1. Clone or prepare a clean working copy of `https://gitee.com/erotica-rbqs/spark-store` for client implementation.
2. Extend the existing FastAPI backend at `https://gitee.com/erotica-rbqs/spark-unionid-server` with account data APIs for favorite folders, favorite items, downloaded records, and richer user profile data.
3. Keep login identity based on the Flarum forum. The client posts credentials directly to Flarum, then sends only the Flarum token and user id to the backend.
4. The login modal provides a register action by opening the forum registration page in the system browser.
5. Replace the upper-left SparkStore title area with an account entry while preserving the logo. After login, replace the logo with the user's avatar and show the username.
6. Add a logged-in account quick menu with user management, favorites, forum home, edit forum profile, and logout actions.
7. Keep anonymous base usage intact: browsing, searching, detail viewing, software install, software uninstall, update center, and installed-app viewing must work without login.
8. Gate account-only features: comments, favorites, cloud favorite management, downloaded record history, and cloud sync require login and show a login/register prompt when used anonymously.
9. Convert app details from modal overlay to a main-content detail page that fills the current app-list area, with a back button returning to the previous list state.
10. Add favorite actions from the detail page. Users can select a favorite folder; if none exists, the backend creates a default folder.
11. Store favorites as application-level identities, not a fixed Spark/APM variant. Batch install selects the currently preferred available variant according to the active priority configuration.
12. Record logged-in user downloads to the cloud when the user clicks download. Downloads made while anonymous are not backfilled after login.
13. On each client start, refresh installed package lists in the background. For logged-in users, ask once before enabling automatic cloud sync; remember the choice.
14. Build the sync list only from store-recognized listed applications, including Spark and APM apps, excluding unknown packages and dependencies.
15. User management shows avatar, nickname, Flarum user level, forum home link, and forum profile edit link.
16. Users can manage multiple favorite folders with custom names, remove invalid/downlisted apps, select all apps in a folder, and send selected installable apps to the existing download/install queue.
17. Favorite folder entries that are not available on the current platform or architecture remain visible with status labels. Downlisted entries remain visible and can be batch removed.
### Excluded
1. Client-side Flarum account creation forms. Registration is a browser link to the forum registration page.
2. Forum profile editing inside the client. The client opens the forum profile page externally.
3. Offline-first conflict resolution for favorites. Backend state is authoritative; client caches may improve UX but do not merge conflicting edits.
4. Automatic install on startup. Users must explicitly send favorites or restore items to the download queue.
5. Admin moderation, report handling, or anti-spam systems.
## Existing Context
The current client is an Electron + Vue 3 + TypeScript application. The target repository is `https://gitee.com/erotica-rbqs/spark-store`.
Important existing integration points:
1. `src/App.vue` coordinates tabs, app loading, install queue integration, detail opening, installed-app modal, and update center.
2. `src/components/AppHeader.vue` owns the top search/settings/about area.
3. `src/components/AppSidebar.vue` owns the current upper-left logo/title area and category navigation.
4. `src/components/AppDetailModal.vue` currently renders app details as an overlay modal. It already handles Spark/APM merged apps and source switching.
5. `src/components/InstalledAppsModal.vue` renders installed apps and origin switching.
6. `src/modules/processInstall.ts` creates download queue items and sends `queue-install` IPC messages.
7. `src/global/storeConfig.ts` owns store URLs and hybrid priority rules through `getHybridDefaultOrigin`.
8. `electron/main/backend/install-manager.ts` exposes install, remove, `check-installed`, `list-installed`, and availability IPC handlers.
9. `list-installed` already supports optimized Spark checks with a `pkgnameList`, and full APM listing marks dependencies by desktop-entry availability. Sync filtering should reuse these facts rather than scanning arbitrary system packages.
The Spark developer manual identifies this repository as the current Electron GUI store (`apm-app-store` behavior), while `amber-pm` owns package-management semantics after commands leave the GUI.
## Backend Design
### User Profile Extension
The existing backend auth flow remains unchanged at the boundary: `POST /auth/flarum` receives `flarum_user_id` and `flarum_token`, validates the token owner, upserts the local user, and returns a Spark Store JWT.
The Flarum validation service should also extract user group data from the authenticated actor when available. The backend stores a compact `forum_level` string and optional `forum_groups` JSON/text summary. If Flarum does not expose groups, `forum_level` falls back to `论坛用户`.
`GET /me` should return existing profile fields plus the forum level fields needed by the client. Existing clients remain compatible because new fields are additive.
### Favorite Folders
Add backend tables:
1. `favorite_folders`: id, user_id, name, created_at, updated_at. Folder names are user-scoped and unique per user. A folder named `默认收藏夹` is created on first favorite action if the user has no folders.
2. `favorite_items`: id, folder_id, app_key, pkgname, name, category, icon_url, created_at. Items are unique per folder and `app_key`.
Favorites are stored as app-level identities. The canonical app key for favorites should be stable across Spark/APM variants when the same user-facing app exists in both sources:
```text
favorite_app_key = app:{category}:{pkgname}
```
The item keeps `pkgname`, display `name`, `category`, and optional `icon_url`. It does not permanently bind to Spark or APM. During install, the client resolves the current catalog entry and chooses Spark/APM according to current availability and priority rules.
Backend endpoints:
1. `GET /me/favorite-folders`: list folders with item counts.
2. `POST /me/favorite-folders`: create folder with custom name.
3. `PATCH /me/favorite-folders/{folder_id}`: rename folder.
4. `DELETE /me/favorite-folders/{folder_id}`: delete folder and its items.
5. `GET /me/favorite-folders/{folder_id}/items`: list items.
6. `POST /me/favorite-folders/{folder_id}/items`: add or idempotently keep an app favorite.
7. `DELETE /me/favorite-folders/{folder_id}/items/{item_id}`: remove one item.
8. `POST /me/favorite-folders/{folder_id}/items/bulk-delete`: remove selected items, including invalid/downlisted entries.
All endpoints require JWT.
### Downloaded Records
Add `downloaded_apps`: id, user_id, app_key, pkgname, name, category, selected_origin, version, package_arch, downloaded_at.
Client behavior:
1. If the user is logged in when clicking download, the client posts a downloaded record after queuing the install task.
2. If the user is anonymous, the download proceeds normally and no cloud record is written.
3. Logging in later does not backfill anonymous downloads.
Backend endpoints:
1. `GET /me/downloaded-apps`: list newest downloaded records with pagination.
2. `POST /me/downloaded-apps`: upsert or append a downloaded record. MVP can append history; duplicate suppression by `user_id`, `app_key`, and `selected_origin` is acceptable if tests define it.
### Installed Sync List
The existing `GET /me/app-list` and `PUT /me/app-list` endpoints remain the default cloud installed-app list.
Client sync payload contains only store-recognized listed apps:
1. App must exist in the current loaded Spark/APM catalog.
2. `category !== "unknown"`.
3. `isDependency !== true`.
4. App has usable `pkgname` and `origin`.
Unknown system packages, dependencies, and packages not in the store catalog are excluded.
## Client Design
### Account Entry And Login
Move the account entry into the upper-left logo/title area currently owned by the sidebar. The logo remains visible while anonymous. The title text becomes `登录 / 注册` with helper text `星火账号`.
After login:
1. The logo image is replaced by the user's avatar when available.
2. The main text is the user's display name or username.
3. Clicking the account entry opens a quick menu.
4. The quick menu includes: `用户管理`, `我的收藏`, `论坛首页`, `修改论坛资料`, and `退出登录`.
Login modal:
1. Contains forum account and password inputs.
2. Posts credentials directly to Flarum `/api/token`.
3. Sends the returned Flarum token and user id to the backend.
4. Provides `注册账号` button that opens the Flarum registration page externally.
5. Never logs or stores the forum password.
### Anonymous Behavior
Anonymous users can still:
1. Browse homepage and categories.
2. Search.
3. View app details.
4. Download/install apps.
5. Remove installed apps.
6. Use update center.
7. View installed apps.
When anonymous users use account-only actions, show a login/register prompt instead of blocking the whole page. Account-only actions are comments, submit review, favorite, cloud favorites, downloaded history, and cloud sync.
### Detail Page
Replace the overlay detail modal with a main-content detail page inside the same area currently used by `AppGrid` and `HomeView`.
State model:
1. `currentView` or equivalent distinguishes `list`, `home`, and `detail`.
2. Opening an app stores the previous list context and selected app.
3. Back returns to the prior list/search/category state.
4. Screen preview can remain an overlay because it is secondary media UI.
The detail page keeps existing detail capabilities:
1. Spark/APM merged app source switch.
2. Install/open/remove actions.
3. Metadata and screenshots.
4. Download count.
New detail capabilities:
1. Favorite button.
2. Favorite folder selector.
3. Comments/reviews panel with login prompt for anonymous users.
4. Download click writes a cloud downloaded record only when logged in.
### Favorites Management
The user management area includes favorite folder management.
Users can:
1. List favorite folders.
2. Create folders with custom names.
3. Rename folders.
4. Delete folders.
5. View folder items.
6. Remove selected items.
7. Batch remove invalid/downlisted items.
8. Select all available items and send them to the download queue.
Availability resolution is client-side because it depends on the current catalog, architecture, Spark/APM availability, store filter, and priority rules.
Favorite item states:
1. `installable`: found in catalog and current source/architecture can install a preferred variant.
2. `installed`: already installed locally.
3. `platform-unavailable`: item exists but neither Spark nor APM variant is usable under current store filter/capability.
4. `arch-unavailable`: item exists in catalog metadata but not for the current architecture.
5. `downlisted`: item no longer exists in the loaded catalog.
Batch install uses current preference:
1. If only one usable variant exists, use it.
2. If both Spark and APM variants exist, use `getHybridDefaultOrigin` and the current store filter/availability to choose.
3. If the preferred variant is unavailable, use the other usable variant.
4. If no usable variant exists, do not queue it and show the reason.
### Downloaded Records
When a logged-in user clicks download/install from detail, favorites, restore, or installed sync restore flows, the client records the selected app to the backend after the queue item is created.
Downloaded records are visible in user management. They are informational and do not alter the install queue automatically.
### Startup Installed Sync
On startup, the client refreshes installed packages in the background after the catalog is loaded enough to provide package lists.
Flow:
1. Load Spark/APM catalog as normal.
2. Call existing `list-installed` IPC for enabled origins. Use optimized package-name checks where possible for Spark and full APM listing where needed, following the current installed modal/update-center patterns.
3. Merge results with catalog metadata.
4. Filter to store-recognized non-dependency apps.
5. If logged in and the user has not made a cloud sync decision, ask once whether to enable automatic installed-list sync.
6. If enabled, upload the default app list via `PUT /me/app-list`.
7. If disabled or anonymous, keep the refreshed list local only.
The confirmation preference is stored locally. A user can later change it from user management.
## Data Flow
### Login
1. User opens login modal from the upper-left account entry.
2. Client posts credentials to Flarum `/api/token`.
3. Flarum returns token and user id.
4. Client posts token and user id to backend `/auth/flarum`.
5. Backend validates token owner, stores profile and forum level, and returns Spark JWT.
6. Client stores Spark JWT and profile in local auth state.
### Favorite Add
1. Logged-in user clicks favorite on detail page.
2. Client fetches or creates favorite folders if needed.
3. User selects a folder.
4. Client posts app-level identity to backend favorite item endpoint.
5. UI updates folder item count and favorite state.
### Batch Install From Favorites
1. User opens a favorite folder.
2. Client resolves each favorite item against current catalog and install facts.
3. UI shows installable, installed, unavailable, arch-unavailable, and downlisted states.
4. User selects installable items or clicks select all installable.
5. Client maps each item to the chosen Spark/APM `App` object based on current priority rules.
6. Client calls existing `handleInstall(app)` for each selected item.
7. If logged in, client records downloaded apps to backend after queueing.
## Error Handling
1. Login failure from Flarum or backend shows a local error in the login modal and clears partial auth state.
2. Expired backend JWT logs the user out or prompts re-login when an account endpoint returns `401`.
3. Favorites and downloaded-record failures do not block base install; show a non-fatal account sync error.
4. Startup sync failure does not block app startup; it shows a user-management warning and can be retried manually.
5. Batch install skips unavailable/downlisted items and reports counts per state.
6. Backend rejects extra fields and invalid lengths with `422`.
## Testing And Verification
Backend tests:
1. Favorite folder creation, default folder creation, rename, delete, and user isolation.
2. Favorite item add/remove/idempotency and folder scoping.
3. Downloaded record create/list and user isolation.
4. Forum level extraction fallback.
5. Existing auth/reviews/app-list tests remain passing.
6. Alembic migration upgrade succeeds.
Client tests:
1. Account entry anonymous/logged-in rendering and quick menu actions.
2. Login modal emits login and opens register URL.
3. Auth state persistence and backend token handling.
4. Detail page replaces the list area and back returns to list state.
5. Favorite folder selector login gating and folder selection.
6. Favorite availability resolver for installable, installed, platform-unavailable, arch-unavailable, and downlisted states.
7. Batch install selects Spark/APM variant according to current priority rules.
8. Startup sync filtering includes only store-listed non-dependency Spark/APM apps.
9. User management renders profile, forum level, forum links, favorite folders, downloaded records, and sync preference.
10. Existing install, update center, installed apps, search, and grid tests remain passing.
Final client verification:
1. `npm run test`
2. `npm run lint`
3. `npm run build:vite`
Final backend verification:
1. `.venv/bin/pytest -v`
2. `DATABASE_URL=<fresh test url> .venv/bin/alembic upgrade head`
## Implementation Notes
1. Use a clean client worktree or fresh clone from `https://gitee.com/erotica-rbqs/spark-store` before implementing. Do not mix previous untracked planning artifacts into code commits.
2. Keep backend and client commits separate.
3. Add `.superpowers/` to `.gitignore` so visual companion artifacts remain local.
4. Preserve existing IPC contracts for install and update flows.
5. Do not change `amber-pm` package-management behavior; only reuse GUI-side install queue paths.
6. Keep UI components focused: account entry/menu, login modal, detail page, favorite selector, user management, and favorite folder manager should be separate components rather than expanding `App.vue` further.
@@ -0,0 +1,374 @@
# Spark Account Reviews Sync Design
## Goal
Add a first-version account feature to Spark Store that uses the existing Flarum forum at `https://bbs.spark-app.store/` as the identity provider, while storing Spark Store-specific data in a new Python + MySQL backend.
The MVP covers:
1. Login with a Flarum account.
2. Show the logged-in user's avatar and display name in the client.
3. Show and submit app detail page comments with 1-5 star ratings.
4. Attach immutable automatic local tags to reviews and support review filtering by those tags.
5. Sync the user's local store-recognized app list so a new device can quickly reinstall old apps.
6. Create a new backend Git repository named `spark-store-backend`.
## Scope
### Included In MVP
1. Client-side Flarum token login.
2. Backend validation of Flarum tokens and backend JWT issuance.
3. User profile display in the Spark Store client.
4. Review list, rating summary, review creation, and editing the current user's own review.
5. Review filtering by automatic tags: app version, package architecture, client architecture, distro, origin, and category.
6. A default per-user cloud app list that is overwritten on each sync.
7. Restore UI that lets users choose cloud-list apps and add them to the existing install queue.
### Excluded From MVP
1. Admin moderation UI.
2. Review reports, bans, anti-spam scoring, or manual approval workflows.
3. Synchronizing reviews into Flarum discussions.
4. Multiple named app-list snapshots or historical list versions.
5. Syncing unknown system packages, dependencies, or every package from `dpkg`/APM.
6. Automatic unattended install on a new device.
## Existing Client Context
The current Spark Store client in this repository is an Electron + Vue 3 + TypeScript app.
Important existing integration points:
1. `src/components/AppDetailModal.vue` owns the app detail modal. It already renders app metadata, screenshots, install/open/remove actions, and download counts.
2. `src/components/InstalledAppsModal.vue` owns the installed-app list UI.
3. `src/App.vue` coordinates modal state, installed-app loading, detail opening, and existing install queue calls.
4. `electron/main/backend/install-manager.ts` already exposes `check-installed` and `list-installed` IPC handlers.
5. Existing install queue behavior should be reused for restore installs rather than creating a second install system.
## Architecture
Use the lightweight new-backend architecture confirmed during brainstorming.
Components:
1. Spark Store client: Vue/Electron UI, local app metadata, local package detection, and install queue integration.
2. Flarum forum: authoritative identity provider for forum username, display name, and avatar.
3. New Python backend: FastAPI service that verifies Flarum tokens, signs Spark Store JWTs, and owns reviews, ratings, and app-list sync data.
4. MySQL database: persistent storage for local user mappings, app keys, reviews, rating aggregates, and synced app-list items.
The new backend must not receive the user's forum password in the selected MVP flow.
## Authentication Flow
1. The client shows a login form for the Flarum username/email and password.
2. The client posts credentials directly to Flarum's token API.
3. Flarum returns an access token and user id.
4. The client sends the Flarum token and user id to the new backend `POST /auth/flarum`.
5. The backend calls Flarum API with that token to verify it and retrieve the user profile.
6. The backend upserts a local user row keyed by `flarum_user_id`.
7. The backend returns a Spark Store JWT plus public profile fields: display name, username, avatar URL, and Flarum user id.
8. The client stores the Spark Store JWT for backend calls and displays the avatar/name in the header or account area.
Logout clears local Spark Store auth state. MVP logout does not need to revoke the Flarum token remotely.
## Review And Rating Design
### App Identity
Reviews are keyed by a stable app key derived from store metadata:
```text
app_key = {origin}:{store_arch}:{category}:{pkgname}
```
Examples:
```text
spark:amd64-store:tools:spark-store
apm:amd64-apm:office:wps
```
This separates Spark and APM apps when they share a package name, while still allowing the UI to show each source independently in the existing hybrid detail modal.
### Automatic Review Tags
When a logged-in user writes a review, the client sends automatic tags derived from the currently viewed app and local system. The user can preview these tags but cannot edit them.
Required tags:
1. `origin`: `spark` or `apm`.
2. `category`: current store category.
3. `pkgname`: current package name.
4. `version`: current app/package version shown in the detail page.
5. `package_arch`: package architecture if known from installed-app data or store filename metadata.
6. `client_arch`: `window.apm_store.arch` such as `amd64`, `arm64`, or `loong64`.
7. `distro`: local Linux distribution id/version when available from the Electron main process.
If `package_arch` or `distro` cannot be detected, the client sends an empty string or `unknown`; the backend stores the value exactly as submitted after validation.
### Review UI
Add a `ReviewsPanel`-style component inside `AppDetailModal.vue`, below the existing app description and screenshot sections.
Behavior:
1. Anonymous users can read reviews and rating summary.
2. Anonymous users see a login prompt instead of the review composer.
3. Logged-in users can select a 1-5 rating and submit text content.
4. The composer displays the automatic tags as read-only pills.
5. The list supports filters for current version, current architecture, current distro, origin, category, and rating.
6. Users can switch filters to view comments under other versions or architectures.
7. The review list uses pagination or cursor-based loading to avoid loading all reviews at once.
### Review Rules
MVP review rules:
1. Rating must be an integer from 1 to 5.
2. Content must be non-empty after trimming and have a backend-enforced maximum length.
3. A user can have one active review per `app_key` plus exact automatic-tag tuple.
4. Submitting again for the same `app_key` and tag tuple updates the existing review.
5. Backend timestamps use UTC.
## App List Sync Design
### Upload Source
The client uses the existing installed-app flow as the source of local software state.
For MVP, upload only apps that satisfy all conditions:
1. App exists in the Spark/APM store catalog loaded by the client.
2. `category !== "unknown"`.
3. `isDependency !== true`.
4. The app has a usable `pkgname` and `origin`.
This intentionally excludes unknown system packages, dependencies, and packages that the store cannot reinstall safely.
### Sync UI
Extend `InstalledAppsModal.vue` with account-aware actions:
1. `同步到账号`: uploads the filtered installed app list as the user's default cloud list.
2. `从账号恢复`: fetches the default cloud list and opens a restore selection view.
The restore view shows each cloud item with one of these states:
1. Already installed locally.
2. Available to install on this client.
3. Not available for the current architecture/source.
The user explicitly selects items and starts restore. Restore uses the existing `handleInstall` and install queue path in the client.
### Cloud List Semantics
MVP maintains one default cloud app list per user.
Each successful sync replaces the previous default list. This avoids list merge conflicts in the first version.
## Backend Repository
Create a new sibling repository:
```text
/home/spark/Desktop/shenmo-spark-store/spark-store-backend
```
Initialize it as a new Git repository and set origin to:
```text
https://gitee.com/momen_official/spark-store-backend.git
```
The initial repository should include a `README.md`. Feature implementation can then add backend code, migrations, tests, and configuration templates.
## Backend Technology
Use:
1. FastAPI for HTTP APIs.
2. SQLAlchemy for ORM models.
3. Alembic for database migrations.
4. PyMySQL or mysqlclient for MySQL connectivity.
5. Pydantic settings for environment configuration.
6. Pytest for backend tests.
Configuration should come from environment variables, with `.env.example` committed and real `.env` ignored.
## Backend API
### Auth
`POST /auth/flarum`
Request:
```json
{
"flarum_user_id": "123",
"flarum_token": "..."
}
```
Response:
```json
{
"access_token": "spark-store-jwt",
"token_type": "bearer",
"user": {
"id": 1,
"flarum_user_id": "123",
"username": "shenmo",
"display_name": "shenmo",
"avatar_url": "https://..."
}
}
```
`GET /me`
Returns the current backend user profile from the Spark Store JWT.
### Reviews
`GET /apps/{app_key}/rating-summary`
Returns average rating, review count, and per-star counts.
`GET /apps/{app_key}/reviews`
Query parameters:
1. `version`
2. `package_arch`
3. `client_arch`
4. `distro`
5. `origin`
6. `category`
7. `rating`
8. `page` and `page_size`
`POST /apps/{app_key}/reviews`
Requires JWT. Creates or updates the current user's review for the same app and automatic-tag tuple.
Request:
```json
{
"rating": 5,
"content": "Works well on my machine.",
"tags": {
"origin": "apm",
"category": "office",
"pkgname": "wps",
"version": "1.0.0",
"package_arch": "amd64",
"client_arch": "amd64",
"distro": "deepin 25"
}
}
```
### App List Sync
`GET /me/app-list`
Requires JWT. Returns the current user's default cloud app list.
`PUT /me/app-list`
Requires JWT. Replaces the current user's default cloud app list.
Request:
```json
{
"client_arch": "amd64",
"distro": "deepin 25",
"items": [
{
"pkgname": "spark-store",
"origin": "spark",
"category": "tools",
"version": "5.1.1",
"package_arch": "amd64",
"app_name": "Spark Store",
"icon_url": "https://..."
}
]
}
```
`POST /me/app-list/install-plan`
Requires JWT. Accepts current client catalog/install facts and returns a normalized plan with installed, installable, and unavailable items. The client may also compute this locally, but the endpoint gives the backend a stable contract for future clients.
## Database Model
Tables:
1. `users`: local user id, Flarum user id, username, display name, avatar URL, timestamps.
2. `apps`: app key, pkgname, origin, store arch, category, latest seen version, timestamps.
3. `reviews`: app id, user id, rating, content, automatic tag columns, timestamps.
4. `user_app_lists`: user id, snapshot name, client arch, distro, timestamps.
5. `user_app_list_items`: list id, pkgname, origin, category, version, package arch, app name, icon URL, timestamps.
Indexes:
1. Unique `users.flarum_user_id`.
2. Unique `apps.app_key`.
3. Index `reviews.app_id` plus tag filter columns.
4. Unique review key for user, app, version, package arch, client arch, distro, origin, and category.
5. Unique default app list per user.
## Error Handling
Client behavior:
1. If Flarum login fails, show a login error without contacting the backend.
2. If backend token exchange fails, show a backend login error and clear partial auth state.
3. If review loading fails, keep the app detail page usable and show a retry affordance in the review panel.
4. If app-list sync fails, keep the local installed-app modal usable and show the failure message near the sync action.
5. If restore install queuing fails for one item, keep the remaining selected items visible and report which item failed.
Backend behavior:
1. Invalid Flarum token returns `401`.
2. Invalid JWT returns `401`.
3. Invalid app key, rating, or tag payload returns `422`.
4. Database errors return `500` with safe generic messages and structured server logs.
## Security Notes
1. The new backend never receives forum passwords in the selected MVP architecture.
2. Real secrets, JWT keys, database URLs, and Flarum tokens must not be committed.
3. The Electron client must avoid logging Flarum tokens and backend JWTs.
4. Backend JWT expiry should be finite; refresh can be handled by reauth in MVP.
5. CORS should be restricted to expected client origins in production.
## Testing And Verification
Client verification:
1. Unit tests for review tag construction.
2. Unit tests for installed-app sync filtering.
3. Component tests for review panel anonymous/logged-in states.
4. Component tests for sync and restore UI states.
5. Existing `npm run lint` and `npm run build:vite` after implementation.
Backend verification:
1. Unit/API tests for `/auth/flarum` with mocked Flarum responses.
2. API tests for review creation, update, listing, filtering, and rating summary.
3. API tests for app-list upload and retrieval.
4. Migration verification against MySQL or a compatible test database.
## Open Implementation Notes
1. Detecting `distro` should be done through Electron main process IPC, preferably by reading `/etc/os-release` and exposing a small safe object to the renderer.
2. Package architecture can come from installed-app data when available; otherwise parse from filename only if reliable, falling back to `unknown`.
3. The existing `AppDetailModal.vue` is already large, so review UI should be isolated into a new child component rather than expanding all logic inline.
4. Restore installation should reuse existing app lookup and `handleInstall` code to preserve Spark/APM origin behavior.
@@ -0,0 +1,43 @@
# App Detail Fixed Sidebar Scroll Design
## Goal
In the app detail popup, keep the left app action/meta area fixed on desktop while only the right content area scrolls. Preserve the existing modal/popup visual style and mobile behavior.
## Scope
This change only affects `src/components/AppDetailModal.vue` and its unit tests. It does not change app identity, review behavior, install behavior, or the surrounding `App.vue` modal flow.
## Desktop Layout
For `lg` and wider screens:
1. The modal overlay remains a centered popup with the existing dim background.
2. `.modal-panel` keeps the rounded card style but stops being the scroll container.
3. `.modal-panel` uses a bounded height and `overflow-hidden` so the popup itself stays fixed.
4. The modal body is split into two columns.
5. The left column contains the return button, app icon/name, source selector, install/open/remove/favorite buttons, and metadata. This column does not scroll with wheel movement over the right side.
6. The right column contains app details, screenshots, and reviews. This column is the independent vertical scroll container.
## Mobile Layout
Below `lg`, keep the existing stacked modal behavior. The modal remains scrollable as a single column so small screens can still access all controls and content.
## Scroll Behavior
Wheel scrolling over normal modal content should scroll the right content column on desktop. The overlay wheel guard remains in place so the background page does not scroll through the modal.
## Testing
Update `AppDetailModal.test.ts` to assert:
1. The popup still renders as a fixed modal overlay with `.modal-panel`.
2. `.modal-panel` uses `overflow-hidden` instead of being the primary vertical scroll container on desktop.
3. The left fixed column and right scroll column have stable test selectors.
## Acceptance Criteria
1. Desktop: left A column remains visually fixed while right B column scrolls.
2. Mobile: stacked layout remains usable.
3. Existing detail modal behavior remains intact.
4. Unit tests and Vite build pass.
@@ -0,0 +1,59 @@
# Client UI Polish Design
## Goal
Fix the client-side account, favorites, review, sync, and shell UI issues reported during QA without changing backend contracts in this pass.
## Scope
This pass is client-only. It can change Vue components, renderer state, Electron window chrome, tests, and docs. It must not invent successful backend behavior for review likes, replies, or deletes until backend endpoints exist.
## User-Visible Requirements
- Long account names must not overflow the sidebar account entry or account quick menu.
- Account quick-menu actions must close the menu immediately after selection.
- User management must open as a global modal/popup overlay instead of replacing only the right content frame.
- User management should render a profile-cover hero when a user has a background/cover URL, with a safe fallback when absent.
- Favorites should not show duplicate default-folder entries.
- The favorite selector must expose a visible new-folder action.
- Clicking a favorite item row should open app detail; the checkbox should remain only for bulk selection.
- The detail favorite button should show favorited state as `已收藏`, and opening it should allow switching folder or cancelling the favorite when client data can identify the existing item.
- Review rating input should use clickable/star UI instead of a native select.
- Reviews should expose architecture and OS/distro filters.
- Review cards should expose user detail entry points from avatar/name and show client-side action affordances for like/reply/delete. Delete must be visibly limited to the author/admin; persistence is deferred until backend APIs exist.
- The system title bar should be replaced by a frameless Electron window with an app-rendered title bar and window controls.
- Selected category capsule color must remain `#2B7FFF`.
- Manual sync must show visible feedback where the user clicked it.
- Restore-from-account should resolve cloud items by same origin first, then fall back to same package across Spark/APM when the exact origin is not loaded.
## Architecture
Keep existing component boundaries and make small additions:
- `App.vue` remains the state coordinator for account modals, favorites, sync, and restore.
- Add a small reusable `UserManagementModal.vue` wrapper around existing `UserManagementView.vue` instead of restructuring the view.
- Add a `WindowTitleBar.vue` component and Electron IPC handlers for minimize/maximize/close, preserving the existing close-to-tray guard.
- Keep review API calls in `backendApi.ts`; client-only review actions emit UI feedback until backend endpoints are added.
- Add pure helper functions for sync restore candidate resolution so cross-source matching can be unit-tested without mounting the full app.
## Data Flow
- Account menu emits actions to `App.vue`; `App.vue` toggles modal state and loads downloaded history before showing user management.
- Favorites are loaded from existing folder/item endpoints. `App.vue` computes current detail favorite metadata from loaded folders/items and passes status to detail components.
- Review filters are local to `ReviewsPanel.vue`; they filter loaded review records by package architecture and distro string.
- Sync feedback is stored in existing `syncStatusMessage` and passed to both `UserManagementView` and `InstalledAppsModal`.
- Restore resolution uses `resolveCloudInstallCandidate(item, apps)` with exact origin/category preference and package-name fallback.
## Deferred Backend Work
The following need backend API work later:
- Persistent review likes.
- Review replies and reply listing.
- Server-authorized review deletion.
- Fetching arbitrary forum user profile details and cover images. This client pass supports cover URLs when present in `SparkUser`; it does not scrape forum HTML.
## Testing
- Add/update unit tests for account modal behavior, quick-menu closing, user cover rendering, favorites selector normalization, star rating, review filters/actions, restore candidate resolution, and titlebar IPC/config.
- Run targeted Vitest files and `npm run build:vite` before completion.
+1
View File
@@ -26,6 +26,7 @@ linux:
Categories: "System;"
mimeTypes:
- "x-scheme-handler/spk"
- "x-scheme-handler/apt"
target:
- "AppImage"
- "deb"
+15 -16
View File
@@ -1,12 +1,12 @@
import { ipcMain, WebContents } from "electron";
import { spawn, ChildProcess, exec } from "node:child_process";
import { promisify } from "node:util";
import { spawn, ChildProcess } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import pino from "pino";
import { ChannelPayload } from "../../typedefinition";
import axios from "axios";
import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser";
const logger = pino({ name: "install-manager" });
@@ -50,22 +50,18 @@ export const tasks = new Map<number, InstallTask>();
let idle = true; // Indicates if the installation manager is idle
export const checkSuperUserCommand = async (): Promise<string> => {
let superUserCmd = "";
const execAsync = promisify(exec);
if (process.getuid && process.getuid() !== 0) {
const { stdout, stderr } = await execAsync("which /usr/bin/pkexec");
if (stderr) {
logger.error("没有找到 pkexec 命令");
return;
}
logger.info(`找到提升权限命令: ${stdout.trim()}`);
superUserCmd = stdout.trim();
if (process.getuid?.() === 0) return "";
if (superUserCmd.length === 0) {
logger.error("没有找到提升权限的命令 pkexec!");
for (const command of SUPER_USER_COMMAND_CANDIDATES) {
const superUserCmd = await findExecutable(command);
if (superUserCmd.length > 0) {
logger.info(`找到提升权限命令: ${superUserCmd}`);
return superUserCmd;
}
}
return superUserCmd;
logger.error("没有找到提升权限的命令 pkexec!");
return "";
};
const runCommandCapture = async (execCommand: string, execParams: string[]) => {
@@ -390,6 +386,7 @@ async function processNextInQueue() {
const aria2Args = [
`--dir=${downloadDir}`,
"--allow-overwrite=true",
"--async-dns=false",
"--summary-interval=1",
"--connect-timeout=10",
"--timeout=15",
@@ -406,7 +403,9 @@ async function processNextInQueue() {
sendStatus("downloading");
// 下载重试逻辑:共10次,5次3秒,3次5秒,2次10秒
const timeoutList = [3000, 3000, 3000, 3000, 3000, 5000, 5000, 5000, 10000, 10000];
const timeoutList = [
3000, 3000, 3000, 3000, 3000, 5000, 5000, 5000, 10000, 10000,
];
let retryCount = 0;
let downloadSuccess = false;
+17 -18
View File
@@ -8,6 +8,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import axios from "axios";
import pino from "pino";
import { findExecutable, SUPER_USER_COMMAND_CANDIDATES } from "./superuser";
const logger = pino({ name: "shared-installer" });
@@ -89,6 +90,7 @@ export const downloadPackage = async ({
const aria2Args = [
`--dir=${downloadDir}`,
"--allow-overwrite=true",
"--async-dns=false",
"--summary-interval=1",
"--connect-timeout=10",
"--timeout=15",
@@ -105,7 +107,9 @@ export const downloadPackage = async ({
onStatus?.("downloading");
// 下载重试逻辑:共10次,5次3秒,3次5秒,2次10秒
const timeoutList = [3000, 3000, 3000, 3000, 3000, 5000, 5000, 5000, 10000, 10000];
const timeoutList = [
3000, 3000, 3000, 3000, 3000, 5000, 5000, 5000, 10000, 10000,
];
let retryCount = 0;
let downloadSuccess = false;
@@ -342,21 +346,16 @@ export const checkApmAvailable = async (): Promise<boolean> => {
* 检查提权命令
*/
export const checkSuperUserCommand = async (): Promise<string> => {
return new Promise((resolve) => {
const child = spawn("which", ["/usr/bin/pkexec"]);
let stdout = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.on("close", (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
resolve("");
}
});
child.on("error", () => {
resolve("");
});
});
if (process.getuid?.() === 0) return "";
for (const command of SUPER_USER_COMMAND_CANDIDATES) {
const superUserCmd = await findExecutable(command);
if (superUserCmd.length > 0) {
logger.info(`找到提升权限命令: ${superUserCmd}`);
return superUserCmd;
}
}
logger.error("没有找到提升权限的命令 pkexec!");
return "";
};
+49
View File
@@ -0,0 +1,49 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export const SUPER_USER_COMMAND_CANDIDATES = [
"/usr/bin/pkexec",
"/run/wrappers/bin/pkexec",
];
const WHICH_TIMEOUT_MS = 5000;
export const findExecutable = async (command: string): Promise<string> => {
if (path.isAbsolute(command)) {
try {
await fs.promises.access(command, fs.constants.X_OK);
return command;
} catch {
return "";
}
}
return await new Promise<string>((resolve) => {
const child = spawn("which", [command]);
let stdout = "";
let settled = false;
const timer = setTimeout(() => {
child.kill();
finish("");
}, WHICH_TIMEOUT_MS);
function finish(result: string) {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
}
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.on("close", (code) => {
finish(code === 0 ? stdout.trim() : "");
});
child.on("error", () => {
finish("");
});
});
};
+18 -1
View File
@@ -45,7 +45,7 @@ class ListenersMap {
}
}
const protocols = ["spk"];
const protocols = ["spk", "apt"];
const listeners = new ListenersMap();
export const deepLink = {
@@ -81,6 +81,23 @@ export function handleCommandLine(commandLine: string[]) {
try {
const url = new URL(target);
// Handle apt:// protocol: convert to spk://search/pkgname
if (url.protocol === "apt:") {
// Format: apt://pkgname
const pkgname =
url.hostname || url.pathname.split("/").filter(Boolean)[0];
if (pkgname) {
const query: Query = { pkgname };
logger.info(`Deep link: apt protocol converted to search: ${pkgname}`);
listeners.emit("search", query);
} else {
logger.warn(
`Deep link: invalid apt format, expected //pkgname, got ${target}`,
);
}
return;
}
const action = url.hostname; // 'search'
logger.info(`Deep link: action found: ${action}`);
File diff suppressed because it is too large Load Diff
+12
View File
@@ -42,6 +42,12 @@ type IpcRendererFacade = {
invoke: typeof ipcRenderer.invoke;
};
type WindowControlBridge = {
minimize: () => void;
toggleMaximize: () => void;
close: () => void;
};
type UpdateCenterStateListener = (snapshot: UpdateCenterSnapshot) => void;
type UpdateCenterStartTask = {
taskKey: string;
@@ -91,6 +97,12 @@ contextBridge.exposeInMainWorld("apm_store", {
})(),
});
contextBridge.exposeInMainWorld("windowControls", {
minimize: () => ipcRenderer.send("window-control-minimize"),
toggleMaximize: () => ipcRenderer.send("window-control-toggle-maximize"),
close: () => ipcRenderer.send("window-control-close"),
} satisfies WindowControlBridge);
contextBridge.exposeInMainWorld("updateCenter", {
open: (storeFilter: StoreFilter = "both"): Promise<UpdateCenterSnapshot> =>
ipcRenderer.invoke("update-center-open", storeFilter),
+6
View File
@@ -23,6 +23,12 @@ if ! command -v apt >/dev/null 2>&1; then
ARGS="$ARGS --no-spark"
fi
# 检查是否是AOSC OS
if grep -q "ID=aosc" /etc/os-release; then
echo "检测到 AOSC OS"
ARGS="$ARGS --no-spark"
fi
# 注意:已移除原先针对 arm64 + wayland 添加 --disable-gpu 的逻辑,
# 现在 arm64 设备无论是否使用 wayland 均不再添加此参数。
+158
View File
@@ -0,0 +1,158 @@
{
lib,
buildNpmPackage,
importNpmLock,
electron,
makeWrapper,
aria2,
apm,
coreutils,
gnugrep,
which,
xdg-utils,
bash,
}:
buildNpmPackage rec {
pname = "spark-store";
version = "5.1.1";
src = lib.cleanSourceWith {
src = ../.;
filter =
path: type:
let
baseName = baseNameOf path;
in
!(lib.elem baseName [
".git"
"dist"
"dist-electron"
"node_modules"
"release"
"result"
]);
};
npmDeps = importNpmLock {
npmRoot = ../.;
};
npmConfigHook = importNpmLock.npmConfigHook;
nativeBuildInputs = [
makeWrapper
];
env = {
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
};
buildPhase = ''
runHook preBuild
npm run build:vite
runHook postBuild
'';
installPhase = ''
runHook preInstall
appDir="$out/share/spark-store"
npm prune --omit=dev --ignore-scripts
substituteInPlace extras/shell-caller.sh \
--replace-fail "/usr/bin/apm" "${lib.getExe' apm "apm"}"
mkdir -p "$appDir" "$out/bin"
cp -r dist dist-electron package.json node_modules extras icons "$appDir"/
chmod -R u+w "$appDir"
find "$appDir/extras" -type f -exec chmod +x {} \;
substituteInPlace "$appDir/dist-electron/main/index.js" \
--replace-fail "/opt/spark-store/extras/shell-caller.sh" "$appDir/extras/shell-caller.sh" \
--replace-fail "/opt/spark-store/extras/app-launcher" "$appDir/extras/app-launcher"
install -Dm644 pkg/usr/share/applications/spark-store.desktop \
"$out/share/applications/spark-store.desktop"
install -Dm644 icons/spark-store.svg \
"$out/share/icons/hicolor/scalable/apps/spark-store.svg"
install -Dm644 icons/spark-store.png \
"$out/share/icons/hicolor/512x512/apps/spark-store.png"
install -Dm644 extras/store.spark-app.spark-store.policy \
"$out/share/polkit-1/actions/store.spark-app.spark-store.policy"
substituteInPlace "$out/share/polkit-1/actions/store.spark-app.spark-store.policy" \
--replace-fail "/opt/spark-store/extras/shell-caller.sh" "$appDir/extras/shell-caller.sh"
cat > "$out/bin/spark-store" <<EOF
#!${bash}/bin/bash
export PATH="${lib.makeBinPath [
aria2
coreutils
gnugrep
which
xdg-utils
]}:\$PATH"
electron_args=(--no-sandbox)
app_args=()
root_path="\$(${coreutils}/bin/readlink -f /proc/self/root)"
if [ "\$root_path" != "/" ]; then
app_args+=(--no-apm)
fi
if [ "\''${IS_ACE_ENV:-}" = "1" ]; then
app_args+=(--no-apm)
fi
if ! command -v apt >/dev/null 2>&1; then
app_args+=(--no-spark)
fi
if [ -r /etc/os-release ] && ${gnugrep}/bin/grep -q "ID=aosc" /etc/os-release; then
app_args+=(--no-spark)
fi
exec ${electron}/bin/electron "\''${electron_args[@]}" "$appDir" "\''${app_args[@]}" "\$@"
EOF
chmod +x "$out/bin/spark-store"
patchShebangs "$appDir/extras"
runHook postInstall
'';
postFixup = ''
appDir="$out/share/spark-store"
wrapProgram "$appDir/extras/shell-caller.sh" \
--prefix PATH : ${
lib.makeBinPath [
aria2
bash
coreutils
which
xdg-utils
]
}
substituteInPlace "$appDir/extras/.shell-caller.sh-wrapped" \
--replace-fail "#!/bin/bash" "#!${bash}/bin/bash"
'';
meta = {
description = "Client for Spark App Store";
homepage = "https://spark-app.store";
license = lib.licenses.gpl3Only;
mainProgram = "spark-store";
platforms = lib.platforms.linux;
};
}
+16 -16
View File
@@ -1,12 +1,12 @@
{
"name": "spark-store",
"version": "5.0.0",
"version": "5.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "spark-store",
"version": "5.0.0",
"version": "5.1.1",
"license": "GPL-3.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
@@ -45,7 +45,7 @@
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.1.4",
"vue": "^3.4.21",
"vue-tsc": "^3.2.4"
"vue-tsc": "^3.3.5"
},
"engines": {
"node": ">=22.12.0"
@@ -3348,18 +3348,18 @@
}
},
"node_modules/@vue/language-core": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.5.tgz",
"integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==",
"version": "3.3.5",
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.5.tgz",
"integrity": "sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==",
"dev": true,
"dependencies": {
"@volar/language-core": "2.4.28",
"@vue/compiler-dom": "^3.5.0",
"@vue/shared": "^3.5.0",
"alien-signals": "^3.0.0",
"alien-signals": "^3.2.0",
"muggle-string": "^0.4.1",
"path-browserify": "^1.0.1",
"picomatch": "^4.0.2"
"picomatch": "^4.0.4"
}
},
"node_modules/@vue/reactivity": {
@@ -3494,9 +3494,9 @@
}
},
"node_modules/alien-signals": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
"integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==",
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
"integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
"dev": true
},
"node_modules/ansi-colors": {
@@ -7636,7 +7636,7 @@
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true
},
@@ -10088,13 +10088,13 @@
}
},
"node_modules/vue-tsc": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz",
"integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==",
"version": "3.3.5",
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.5.tgz",
"integrity": "sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==",
"dev": true,
"dependencies": {
"@volar/typescript": "2.4.28",
"@vue/language-core": "3.2.5"
"@vue/language-core": "3.3.5"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "spark-store",
"version": "5.1.0",
"version": "5.2.0-alpha.1",
"main": "dist-electron/main/index.js",
"description": "Client for Spark App Store",
"author": "elysia-best <elysia-best@simplelinux.cn.eu.org>",
@@ -74,7 +74,7 @@
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.1.4",
"vue": "^3.4.21",
"vue-tsc": "^3.2.4"
"vue-tsc": "^3.3.5"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
@@ -10,4 +10,4 @@ Keywords=appstore;
Terminal=false
StartupNotify=true
StartupWMClass=spark-store
MimeType=x-scheme-handler/spk
MimeType=x-scheme-handler/spk;x-scheme-handler/apt
@@ -0,0 +1,5 @@
#!/bin/bash
TRANSHELL_CONTENT_RUNNING_IN_NOT_ROOT_USER="Informação: Iniciando em modo sem privilégios root! Se ocorrerem problemas, tente executar o comando com privilégios de root."
TRANSHELL_CONTENT_INFO_SOURCES_LIST_D_IS_EMPTY="Informação: A pasta sources.list.d está vazia. Nenhuma sincronização será tentada."
TRANSHELL_CONTENT_GETTING_SERVER_CONFIG_AND_MIRROR_LIST="Obtendo configuração do servidor e lista de espelhos..."
TRANSHELL_CONTENT_PLEASE_USE_APTSS_INSTEAD_OF_APT="Aviso: Embora a mensagem de erro sugira usar apt (ex.: apt install --fix-broken) para corrigir o problema, ao depurar o erro, utilize aptss no lugar (no exemplo, use aptss install --fix-broken)."
@@ -37,6 +37,7 @@ void DownloadManager::startDownload(const QString &packageName, const QString &u
QStringList arguments = {
"--enable-rpc=false",
"--console-log-level=warn",
"--async-dns=false",
"--summary-interval=1",
"--allow-overwrite=true",
"--connect-timeout=30",
+1492 -102
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -20,11 +20,13 @@ Object.defineProperty(window, "ipcRenderer", {
invoke: vi.fn(),
removeListener: vi.fn(),
},
writable: true,
});
// Mock window.apm_store
Object.defineProperty(window, "apm_store", {
value: {
arch: "amd64-store",
arch: "amd64",
},
writable: true,
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,452 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App.vue";
import { recordDownloadedApp } from "@/modules/backendApi";
import { setAuthSession } from "@/global/authState";
import { downloads } from "@/global/downloadStatus";
import type { DownloadResult } from "@/global/typedefinition";
const invoke = vi.fn();
const send = vi.fn();
const ipcHandlers = new Map<string, (...args: unknown[]) => void>();
const setSecondUserSession = () => {
setAuthSession({
accessToken: "backend-token-b",
tokenType: "bearer",
user: {
id: 2,
flarumUserId: "84",
username: "second",
displayName: "Second User",
avatarUrl: "https://bbs.spark-app.store/avatar-b.png",
forumLevel: "用户",
forumGroups: ["用户"],
},
});
};
const setInitialUserSession = () => {
setAuthSession({
accessToken: "backend-token",
tokenType: "bearer",
user: {
id: 1,
flarumUserId: "42",
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.png",
forumLevel: "管理员",
forumGroups: ["管理员"],
},
});
};
const createControlledPromise = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
};
vi.mock("axios", () => {
const get = vi.fn(async (url: string) => {
if (url.includes("categories.json")) {
return { data: { office: { zh: "办公" } } };
}
if (url.includes("/office/applist.json")) {
return {
data: [
{
Name: "WPS",
Pkgname: "wps",
Version: "1.0.0",
Filename: "wps_1.0.0_amd64.deb",
Torrent_address: "",
Author: "",
Contributor: "",
Website: "",
Update: "",
Size: "",
More: "Office suite",
Tags: "",
img_urls: "[]",
icons: "",
},
],
};
}
return { data: [] };
});
const post = vi.fn(async () => ({ data: { ok: true } }));
return {
default: {
create: () => ({ get, post }),
},
};
});
vi.mock("@/modules/updateCenter", () => ({
createUpdateCenterStore: () => ({
isOpen: { value: false },
showCloseConfirm: { value: false },
showMigrationConfirm: { value: false },
searchQuery: { value: "" },
selectedTaskKeys: { value: new Set<string>() },
snapshot: {
value: { items: [], tasks: [], warnings: [], hasRunningTasks: false },
},
filteredItems: { value: [] },
allSelected: { value: false },
someSelected: { value: false },
bind: vi.fn(),
unbind: vi.fn(),
open: vi.fn(),
refresh: vi.fn(),
ignoreItem: vi.fn(),
unignoreItem: vi.fn(),
toggleSelection: vi.fn(),
toggleSelectAll: vi.fn(),
getSelectedItems: vi.fn(() => []),
closeNow: vi.fn(),
startSelected: vi.fn(),
requestClose: vi.fn(),
}),
}));
vi.mock("@/modules/backendApi", () => ({
addFavoriteItem: vi.fn(),
bulkDeleteFavoriteItems: vi.fn(),
createFavoriteFolder: vi.fn(),
exchangeFlarumToken: vi.fn(),
listFavoriteFolders: vi.fn(async () => []),
listFavoriteItems: vi.fn(async () => []),
recordDownloadedApp: vi.fn(async () => undefined),
setBackendToken: vi.fn(),
}));
describe("App download records", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.clearAllMocks();
ipcHandlers.clear();
downloads.value = [];
invoke.mockImplementation(async (channel: string) => {
if (channel === "get-store-filter") return "apm";
if (channel === "check-spark-available") return false;
if (channel === "check-apm-available") return true;
if (channel === "get-app-version") return "5.0.0";
if (channel === "get-system-info") return { distro: "deepin 25" };
if (channel === "list-installed") return { success: true, apps: [] };
if (channel === "check-installed") return false;
return [];
});
Object.assign(window.ipcRenderer, {
invoke,
send,
on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => {
ipcHandlers.set(channel, handler);
}),
off: vi.fn(),
});
window.apm_store.arch = "amd64";
localStorage.clear();
setInitialUserSession();
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
vi.stubGlobal("scrollTo", vi.fn());
class MockIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
});
it("records a download only after the queued install completes successfully", async () => {
render(App);
await fireEvent.click(
await screen.findByRole("button", { name: "全部应用 1" }),
);
await fireEvent.click(await screen.findByText("WPS"));
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
await waitFor(() => {
expect(send).toHaveBeenCalledWith(
"queue-install",
expect.stringContaining('"pkgname":"wps"'),
);
});
expect(recordDownloadedApp).not.toHaveBeenCalled();
const queuedPayload = vi
.mocked(send)
.mock.calls.find(
([channel]) => channel === "queue-install",
)?.[1] as string;
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
const completion: DownloadResult = {
id: queuedDownload.id,
time: Date.now(),
message: "installed",
success: true,
exitCode: 0,
status: "completed",
origin: "apm",
};
ipcHandlers.get("install-complete")?.({}, completion);
await waitFor(() => {
expect(recordDownloadedApp).toHaveBeenCalledWith(
expect.objectContaining({
appKey: "app:office:wps",
pkgname: "wps",
selectedOrigin: "apm",
}),
);
});
});
it("keeps a pending download record through a failed install retry", async () => {
render(App);
await fireEvent.click(
await screen.findByRole("button", { name: "全部应用 1" }),
);
await fireEvent.click(await screen.findByText("WPS"));
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
await waitFor(() => {
expect(send).toHaveBeenCalledWith(
"queue-install",
expect.stringContaining('"pkgname":"wps"'),
);
});
const queuedPayload = vi
.mocked(send)
.mock.calls.find(
([channel]) => channel === "queue-install",
)?.[1] as string;
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
const failedCompletion: DownloadResult = {
id: queuedDownload.id,
time: Date.now(),
message: "failed",
success: false,
exitCode: 1,
status: "failed",
origin: "apm",
};
ipcHandlers.get("install-complete")?.({}, failedCompletion);
downloads.value[0].status = "failed";
await waitFor(() => {
expect(recordDownloadedApp).not.toHaveBeenCalled();
expect(screen.getByTitle("重试")).toBeInTheDocument();
});
await fireEvent.click(screen.getByTitle("重试"));
const successfulCompletion: DownloadResult = {
id: queuedDownload.id,
time: Date.now(),
message: "installed",
success: true,
exitCode: 0,
status: "completed",
origin: "apm",
};
ipcHandlers.get("install-complete")?.({}, successfulCompletion);
await waitFor(() => {
expect(recordDownloadedApp).toHaveBeenCalledTimes(1);
expect(recordDownloadedApp).toHaveBeenCalledWith(
expect.objectContaining({
appKey: "app:office:wps",
pkgname: "wps",
name: "WPS",
category: "office",
selectedOrigin: "apm",
version: "1.0.0",
}),
);
});
});
it("does not record a queued install under a later logged-in user", async () => {
render(App);
await fireEvent.click(
await screen.findByRole("button", { name: "全部应用 1" }),
);
await fireEvent.click(await screen.findByText("WPS"));
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
await waitFor(() => {
expect(send).toHaveBeenCalledWith(
"queue-install",
expect.stringContaining('"pkgname":"wps"'),
);
});
const queuedPayload = vi
.mocked(send)
.mock.calls.find(
([channel]) => channel === "queue-install",
)?.[1] as string;
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
await fireEvent.click(screen.getByRole("button", { name: "Momen" }));
await fireEvent.click(await screen.findByText("退出登录"));
setSecondUserSession();
const completion: DownloadResult = {
id: queuedDownload.id,
time: Date.now(),
message: "installed",
success: true,
exitCode: 0,
status: "completed",
origin: "apm",
};
ipcHandlers.get("install-complete")?.({}, completion);
await waitFor(() => {
expect(recordDownloadedApp).not.toHaveBeenCalled();
});
});
it("does not bind a queued install to a user who logged in during the APM availability check", async () => {
const apmCheck = createControlledPromise<boolean>();
let apmCheckCalls = 0;
invoke.mockImplementation(async (channel: string) => {
if (channel === "get-store-filter") return "apm";
if (channel === "check-spark-available") return false;
if (channel === "check-apm-available") {
apmCheckCalls += 1;
return apmCheckCalls === 1 ? true : apmCheck.promise;
}
if (channel === "get-app-version") return "5.0.0";
if (channel === "get-system-info") return { distro: "deepin 25" };
if (channel === "list-installed") return { success: true, apps: [] };
if (channel === "check-installed") return false;
return [];
});
render(App);
await fireEvent.click(
await screen.findByRole("button", { name: "全部应用 1" }),
);
await fireEvent.click(await screen.findByText("WPS"));
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
await waitFor(() => {
expect(apmCheckCalls).toBe(2);
});
setSecondUserSession();
apmCheck.resolve(true);
await waitFor(() => {
expect(send).toHaveBeenCalledWith(
"queue-install",
expect.stringContaining('"pkgname":"wps"'),
);
});
const queuedPayload = vi
.mocked(send)
.mock.calls.find(
([channel]) => channel === "queue-install",
)?.[1] as string;
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
const completion: DownloadResult = {
id: queuedDownload.id,
time: Date.now(),
message: "installed",
success: true,
exitCode: 0,
status: "completed",
origin: "apm",
};
ipcHandlers.get("install-complete")?.({}, completion);
await waitFor(() => {
expect(recordDownloadedApp).not.toHaveBeenCalled();
});
});
it("cleans up a successful pending record even when the active user does not match", async () => {
render(App);
await fireEvent.click(
await screen.findByRole("button", { name: "全部应用 1" }),
);
await fireEvent.click(await screen.findByText("WPS"));
await fireEvent.click(await screen.findByRole("button", { name: "安装" }));
await waitFor(() => {
expect(send).toHaveBeenCalledWith(
"queue-install",
expect.stringContaining('"pkgname":"wps"'),
);
});
const queuedPayload = vi
.mocked(send)
.mock.calls.find(
([channel]) => channel === "queue-install",
)?.[1] as string;
const queuedDownload = JSON.parse(queuedPayload) as { id: number };
const completion: DownloadResult = {
id: queuedDownload.id,
time: Date.now(),
message: "installed",
success: true,
exitCode: 0,
status: "completed",
origin: "apm",
};
setSecondUserSession();
ipcHandlers.get("install-complete")?.({}, completion);
await waitFor(() => {
expect(recordDownloadedApp).not.toHaveBeenCalled();
});
setInitialUserSession();
ipcHandlers.get("install-complete")?.({}, completion);
await waitFor(() => {
expect(recordDownloadedApp).not.toHaveBeenCalled();
});
});
});
+212
View File
@@ -0,0 +1,212 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppDetailModal from "@/components/AppDetailModal.vue";
import type { App, ReviewTags } from "@/global/typedefinition";
vi.mock("axios", () => ({
default: {
get: vi.fn(async () => ({ status: 200, data: "42" })),
},
}));
vi.mock("@/components/ReviewsPanel.vue", () => ({
default: {
name: "ReviewsPanel",
props: ["appKey", "tags", "loggedIn", "canSubmit"],
emits: ["request-login", "show-user"],
template:
'<button type="button" data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)" @click="$emit(\'show-user\', { id: 31, userDisplayName: \'Detail User\', userAvatarUrl: \'\', rating: 5, content: \'\', version: tags.version, packageArch: tags.packageArch, clientArch: tags.clientArch, distro: tags.distro, origin: tags.origin, category: tags.category, createdAt: \'\', updatedAt: \'\' })"></button>',
},
}));
const app: App = {
name: "WPS",
pkgname: "wps",
version: "1.0.0",
filename: "wps_1.0.0_amd64.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "110M",
more: "Office suite",
tags: "office",
img_urls: [],
icons: "",
category: "office",
origin: "apm",
currentStatus: "not-installed",
};
const sparkApp: App = {
...app,
name: "WPS Spark",
version: "2.0.0",
filename: "wps_2.0.0_amd64.deb",
origin: "spark",
};
const apmApp: App = {
...app,
name: "WPS APM",
origin: "apm",
};
const mergedApp: App = {
...sparkApp,
isMerged: true,
sparkApp,
apmApp,
viewingOrigin: "spark",
};
const sparkTags: ReviewTags = {
origin: "spark",
category: "office",
pkgname: "wps",
version: "2.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
};
describe("AppDetailModal", () => {
beforeEach(() => {
window.apm_store.arch = "amd64";
});
it("renders detail content inside a popup-style modal overlay", () => {
const { container } = render(AppDetailModal, {
attrs: { "data-app-modal": "detail" },
props: {
show: true,
app,
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
loggedIn: false,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
const overlay = container.querySelector('[data-app-modal="detail"]');
expect(overlay).toBeTruthy();
expect(overlay?.className).toContain("fixed");
const panel = overlay?.querySelector(".modal-panel");
expect(panel).toBeTruthy();
expect(panel?.className).toContain("overflow-hidden");
expect(panel?.className).toContain("lg:max-h-[85vh]");
expect(
panel?.querySelector('[data-testid="detail-fixed-sidebar"]'),
).toBeTruthy();
expect(
panel?.querySelector('[data-testid="detail-scroll-content"]'),
).toBeTruthy();
expect(
panel?.querySelector('[data-testid="detail-scroll-content"]')?.className,
).toContain("lg:overflow-y-auto");
});
it("updates review identity when switching a merged app origin", async () => {
const rendered = render(AppDetailModal, {
props: {
show: true,
app: mergedApp,
screenshots: [],
sparkInstalled: true,
apmInstalled: true,
loggedIn: true,
reviewAppKey: "spark:amd64-store:office:wps",
reviewTags: sparkTags,
},
});
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-app-key",
"spark:amd64-store:office:wps",
);
await fireEvent.click(screen.getByRole("button", { name: "APM" }));
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-app-key",
"apm:amd64-apm:office:wps",
);
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-origin",
"apm",
);
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-version",
"1.0.0",
);
expect(rendered.emitted("select-origin")?.[0]?.[0]).toBe("apm");
});
it("marks reviews read-only when the selected origin is not installed", () => {
render(AppDetailModal, {
props: {
show: true,
app,
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
loggedIn: true,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-can-submit",
"false",
);
});
it("forwards review user profile events", async () => {
const rendered = render(AppDetailModal, {
props: {
show: true,
app,
screenshots: [],
sparkInstalled: true,
apmInstalled: true,
loggedIn: true,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
await fireEvent.click(screen.getByTestId("reviews-panel"));
expect(rendered.emitted("show-user")?.[0]?.[0]).toEqual(
expect.objectContaining({ userDisplayName: "Detail User" }),
);
});
it("renders favorited state with folder name and still emits favorite", async () => {
const rendered = render(AppDetailModal, {
props: {
show: true,
app,
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
loggedIn: true,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
favorited: true,
favoriteFolderName: "办公收藏",
},
});
await fireEvent.click(
screen.getByRole("button", { name: "已收藏 · 办公收藏" }),
);
expect(rendered.emitted("favorite")?.[0]?.[0]).toEqual(app);
});
});
+193
View File
@@ -0,0 +1,193 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it, vi } from "vitest";
import AppDetailPage from "@/components/AppDetailPage.vue";
import type { App, ReviewTags } from "@/global/typedefinition";
vi.mock("@/components/ReviewsPanel.vue", () => ({
default: {
name: "ReviewsPanel",
props: ["appKey", "tags", "loggedIn", "canSubmit"],
emits: ["request-login", "show-user"],
template:
'<button type="button" data-testid="reviews-panel" :data-app-key="appKey" :data-origin="tags.origin" :data-version="tags.version" :data-can-submit="String(canSubmit)" @click="$emit(\'show-user\', { id: 31, userDisplayName: \'Detail User\', userAvatarUrl: \'\', rating: 5, content: \'\', version: tags.version, packageArch: tags.packageArch, clientArch: tags.clientArch, distro: tags.distro, origin: tags.origin, category: tags.category, createdAt: \'\', updatedAt: \'\' })"></button>',
},
}));
const app: App = {
name: "WPS",
pkgname: "wps",
version: "1.0.0",
filename: "wps_1.0.0_amd64.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "110M",
more: "Office suite",
tags: "office",
img_urls: [],
icons: "",
category: "office",
origin: "apm",
currentStatus: "not-installed",
};
const sparkApp: App = {
...app,
name: "WPS Spark",
version: "2.0.0",
filename: "wps_2.0.0_amd64.deb",
origin: "spark",
};
const apmApp: App = {
...app,
name: "WPS APM",
version: "1.0.0",
filename: "wps_1.0.0_amd64.deb",
origin: "apm",
};
const mergedApp: App = {
...sparkApp,
isMerged: true,
sparkApp,
apmApp,
viewingOrigin: "spark",
};
const sparkTags: ReviewTags = {
origin: "spark",
category: "office",
pkgname: "wps",
version: "2.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
};
describe("AppDetailPage", () => {
it("renders as page, emits back, and gates favorite for anonymous users", async () => {
const rendered = render(AppDetailPage, {
props: {
app,
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
loggedIn: false,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: null,
},
});
expect(screen.getByText("Office suite")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "返回" }));
await fireEvent.click(screen.getByRole("button", { name: "收藏" }));
expect(rendered.emitted("back")).toHaveLength(1);
expect(rendered.emitted("request-login")?.[0]?.[0]).toBe(
"收藏应用需要登录星火账号。",
);
});
it("gates reviews for anonymous users", async () => {
const rendered = render(AppDetailPage, {
props: {
app,
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
loggedIn: false,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
expect(screen.queryByTestId("reviews-panel")).toBeNull();
await fireEvent.click(
screen.getByRole("button", { name: "登录后查看评价" }),
);
expect(rendered.emitted("request-login")?.[0]?.[0]).toBe(
"登录后查看和发表评论。",
);
});
it("updates review identity when switching a merged app origin", async () => {
render(AppDetailPage, {
props: {
app: mergedApp,
screenshots: [],
sparkInstalled: true,
apmInstalled: true,
loggedIn: true,
reviewAppKey: "spark:amd64-store:office:wps",
reviewTags: sparkTags,
},
});
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-app-key",
"spark:amd64-store:office:wps",
);
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-origin",
"spark",
);
await fireEvent.click(screen.getByRole("button", { name: "APM" }));
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-app-key",
"apm:amd64-apm:office:wps",
);
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-origin",
"apm",
);
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-version",
"1.0.0",
);
});
it("marks reviews read-only when the selected origin is not installed", () => {
render(AppDetailPage, {
props: {
app,
screenshots: [],
sparkInstalled: false,
apmInstalled: false,
loggedIn: true,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
expect(screen.getByTestId("reviews-panel")).toHaveAttribute(
"data-can-submit",
"false",
);
});
it("forwards review user profile events", async () => {
const rendered = render(AppDetailPage, {
props: {
app,
screenshots: [],
sparkInstalled: true,
apmInstalled: true,
loggedIn: true,
reviewAppKey: "apm:amd64-apm:office:wps",
reviewTags: sparkTags,
},
});
await fireEvent.click(screen.getByTestId("reviews-panel"));
expect(rendered.emitted("show-user")?.[0]?.[0]).toEqual(
expect.objectContaining({ userDisplayName: "Detail User" }),
);
});
});
@@ -0,0 +1,113 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import AppListRestoreModal from "@/components/AppListRestoreModal.vue";
import type { SyncedAppListItem } from "@/global/typedefinition";
const createItem = (
overrides: Partial<SyncedAppListItem> = {},
): SyncedAppListItem => ({
pkgname: "spark-notes",
origin: "spark",
category: "office",
version: "1.0.0",
packageArch: "amd64",
appName: "Spark Notes",
iconUrl: "",
...overrides,
});
describe("AppListRestoreModal", () => {
it("emits selected installable cloud items", async () => {
const rendered = render(AppListRestoreModal, {
props: {
show: true,
loading: false,
error: "",
items: [
createItem(),
createItem({ pkgname: "amber-ce", appName: "Amber CE" }),
],
installedKeys: new Set<string>(),
},
});
await fireEvent.click(screen.getByLabelText("Spark Notes"));
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
expect.objectContaining({ pkgname: "spark-notes" }),
]);
});
it("disables already installed cloud items", () => {
render(AppListRestoreModal, {
props: {
show: true,
loading: false,
error: "",
items: [createItem()],
installedKeys: new Set(["spark:spark-notes"]),
},
});
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
expect(screen.getByText("已安装")).toBeTruthy();
});
it("treats the same package installed from another source as installed", () => {
render(AppListRestoreModal, {
props: {
show: true,
loading: false,
error: "",
items: [createItem({ origin: "spark" })],
installedKeys: new Set(["apm:spark-notes"]),
installedPackageKeys: new Set(["spark-notes"]),
},
});
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
expect(screen.getByText("已安装")).toBeTruthy();
});
it("removes selected items when they become installed", async () => {
const rendered = render(AppListRestoreModal, {
props: {
show: true,
loading: false,
error: "",
items: [createItem()],
installedKeys: new Set<string>(),
},
});
await fireEvent.click(screen.getByLabelText("Spark Notes"));
await rendered.rerender({ installedKeys: new Set(["spark:spark-notes"]) });
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
});
it("removes selected items when the same package becomes installed from another source", async () => {
const rendered = render(AppListRestoreModal, {
props: {
show: true,
loading: false,
error: "",
items: [createItem({ origin: "spark" })],
installedKeys: new Set<string>(),
installedPackageKeys: new Set<string>(),
},
});
await fireEvent.click(screen.getByLabelText("Spark Notes"));
await rendered.rerender({
installedPackageKeys: new Set(["spark-notes"]),
});
expect(screen.getByLabelText("Spark Notes")).toBeDisabled();
expect(screen.getByLabelText("Spark Notes")).not.toBeChecked();
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
});
});
@@ -0,0 +1,116 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import AppSidebar from "@/components/AppSidebar.vue";
import type { SparkUser } from "@/global/typedefinition";
const baseProps = {
activeTab: "all",
categoryCounts: { all: 0 },
themeMode: "auto" as const,
storeFilter: "both" as const,
sparkAvailable: true,
apmAvailable: true,
sidebarEntries: [],
entryCounts: {},
};
const user: SparkUser = {
id: 1,
flarumUserId: "123",
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.png",
forumLevel: "管理员",
forumGroups: ["管理员"],
};
describe("AppSidebar account entry", () => {
it("prompts login when anonymous", async () => {
const rendered = render(AppSidebar, {
props: { ...baseProps, currentUser: null },
});
await fireEvent.click(screen.getByRole("button", { name: /登录 \/ 注册/ }));
expect(rendered.emitted("request-login")).toHaveLength(1);
});
it("opens quick menu for logged-in users", async () => {
render(AppSidebar, { props: { ...baseProps, currentUser: user } });
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
expect(screen.getByText("用户管理")).toBeTruthy();
expect(screen.getByText("我的收藏")).toBeTruthy();
expect(screen.getByText("退出登录")).toBeTruthy();
});
it("closes the quick menu after clicking outside the account area", async () => {
render(AppSidebar, { props: { ...baseProps, currentUser: user } });
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
expect(screen.getByText("用户管理")).toBeTruthy();
await fireEvent.mouseDown(document.body);
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
});
it("keeps long account names inside the sidebar account entry", () => {
const longUser: SparkUser = {
...user,
username: "SuperEndermanSMSuperEndermanSMSuperEndermanSM",
displayName: "",
};
const { container } = render(AppSidebar, {
props: { ...baseProps, currentUser: longUser },
});
const accountButton = screen.getByRole("button", {
name: /SuperEndermanSM/,
});
const textWrapper = accountButton.querySelector(
"[data-testid='account-text']",
);
const accountName = screen.getByText(longUser.username);
expect(textWrapper?.className).toContain("min-w-0");
expect(accountName.className).toContain("truncate");
expect(container.textContent).toContain(longUser.username);
});
it.each([
["用户管理", "open-user-management"],
["我的收藏", "open-favorites"],
["论坛首页", "open-forum"],
["修改论坛资料", "edit-profile"],
["退出登录", "logout"],
] as const)(
"closes the quick menu after selecting %s",
async (label, eventName) => {
const rendered = render(AppSidebar, {
props: { ...baseProps, currentUser: user },
});
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
await fireEvent.click(screen.getByRole("button", { name: label }));
expect(rendered.emitted(eventName)).toHaveLength(1);
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
},
);
it("closes the quick menu after selecting a sidebar action", async () => {
const rendered = render(AppSidebar, {
props: { ...baseProps, currentUser: user },
});
await fireEvent.click(screen.getByRole("button", { name: /Momen/ }));
await fireEvent.click(screen.getByRole("button", { name: "应用管理" }));
expect(rendered.emitted("list")).toHaveLength(1);
expect(screen.queryByRole("button", { name: "用户管理" })).toBeNull();
});
});
+4 -2
View File
@@ -8,13 +8,15 @@ const renderSidebar = (
) => {
return render(AppSidebar, {
props: {
categories: {},
activeCategory: "all",
activeTab: "all",
categoryCounts: { all: 0 },
themeMode: "auto",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
sidebarEntries: [],
entryCounts: {},
currentUser: null,
...overrides,
},
});
+19
View File
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const categoryBarSource = readFileSync(
resolve(
dirname(fileURLToPath(import.meta.url)),
"../../components/CategoryBar.vue",
),
"utf-8",
);
describe("CategoryBar", () => {
it("uses the requested blue for the selected category pill", () => {
expect(categoryBarSource.toLowerCase()).toContain("background: #2b7fff;");
});
});
@@ -0,0 +1,156 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import FavoriteFolderManager from "@/components/FavoriteFolderManager.vue";
import type {
App,
FavoriteFolder,
ResolvedFavoriteItem,
} from "@/global/typedefinition";
const folder: FavoriteFolder = {
id: 1,
name: "默认收藏夹",
itemCount: 1,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
};
const item: ResolvedFavoriteItem = {
item: {
id: 2,
appKey: "app:office:wps",
pkgname: "wps",
name: "WPS",
category: "office",
iconUrl: "",
createdAt: "2026-05-18T00:00:00Z",
},
status: "downlisted",
reason: "已下架",
selectedApp: null,
};
const selectedApp: App = {
name: "WPS",
pkgname: "wps",
version: "1.0.0",
filename: "wps_1.0.0_amd64.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "110M",
more: "Office suite",
tags: "office",
img_urls: [],
icons: "",
category: "office",
origin: "apm",
currentStatus: "not-installed",
};
describe("FavoriteFolderManager", () => {
it("shows downlisted favorites and emits bulk delete", async () => {
const rendered = render(FavoriteFolderManager, {
props: {
folders: [folder],
activeFolderId: 1,
items: [item],
loading: false,
error: "",
},
});
expect(screen.getByText("已下架")).toBeTruthy();
await fireEvent.click(screen.getByLabelText("选择 WPS"));
await fireEvent.click(screen.getByRole("button", { name: "移除选中" }));
expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
});
it("opens a favorite item's app detail from the row content", async () => {
const rendered = render(FavoriteFolderManager, {
props: {
folders: [folder],
activeFolderId: 1,
items: [{ ...item, status: "installable", selectedApp }],
loading: false,
error: "",
},
});
await fireEvent.click(
screen.getByRole("button", { name: "打开 WPS 详情" }),
);
expect(rendered.emitted("open-detail")?.[0]?.[0]).toEqual(selectedApp);
expect(rendered.emitted("remove-selected")).toBeUndefined();
});
it("keeps checkbox selection isolated from opening app detail", async () => {
const rendered = render(FavoriteFolderManager, {
props: {
folders: [folder],
activeFolderId: 1,
items: [{ ...item, status: "installable", selectedApp }],
loading: false,
error: "",
},
});
await fireEvent.click(screen.getByLabelText("选择 WPS"));
expect(rendered.emitted("open-detail")).toBeUndefined();
await fireEvent.click(screen.getByRole("button", { name: "移除选中" }));
expect(rendered.emitted("remove-selected")?.[0]?.[0]).toEqual([2]);
});
it("selects installable favorites and emits them for installation", async () => {
const installableItem: ResolvedFavoriteItem = {
...item,
status: "installable",
reason: "可安装",
selectedApp,
};
const installedItem: ResolvedFavoriteItem = {
...item,
item: {
...item.item,
id: 3,
pkgname: "installed-app",
name: "已安装应用",
},
status: "installed",
reason: "已安装",
selectedApp: {
...selectedApp,
pkgname: "installed-app",
name: "已安装应用",
},
};
const rendered = render(FavoriteFolderManager, {
props: {
folders: [folder],
activeFolderId: 1,
items: [installableItem, installedItem],
loading: false,
error: "",
},
});
expect(screen.getByRole("button", { name: "加入安装队列" })).toBeDisabled();
await fireEvent.click(screen.getByRole("button", { name: "选择可安装" }));
expect(screen.getByLabelText("选择 WPS")).toBeChecked();
expect(screen.getByLabelText("选择 已安装应用")).not.toBeChecked();
expect(screen.getByText("已选择 1 个可安装应用")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" }));
expect(rendered.emitted("install-selected")?.[0]?.[0]).toEqual([
installableItem,
]);
});
});
@@ -0,0 +1,138 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import FavoriteFolderSelector from "@/components/FavoriteFolderSelector.vue";
import type { FavoriteFolder } from "@/global/typedefinition";
const defaultFolder: FavoriteFolder = {
id: 1,
name: "默认收藏夹",
itemCount: 1,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
};
describe("FavoriteFolderSelector", () => {
it("renders above the app detail modal and its child popups", () => {
const { container } = render(FavoriteFolderSelector, {
props: {
show: true,
folders: [],
},
});
const overlay = container.firstElementChild;
expect(overlay?.className).toContain("z-[90]");
});
it("does not duplicate the default folder returned by the backend", () => {
render(FavoriteFolderSelector, {
props: {
show: true,
folders: [defaultFolder],
},
});
expect(
screen.getAllByRole("checkbox", { name: "收藏到 默认收藏夹" }),
).toHaveLength(1);
});
it("normalizes backend default folder names before adding fallback default", () => {
render(FavoriteFolderSelector, {
props: {
show: true,
folders: [{ ...defaultFolder, name: " 默认收藏夹 " }],
},
});
expect(
screen.getAllByRole("checkbox", { name: /收藏到\s*默认收藏夹/ }),
).toHaveLength(1);
});
it("offers creating a folder while selecting favorites", async () => {
const rendered = render(FavoriteFolderSelector, {
props: {
show: true,
folders: [defaultFolder],
},
});
await fireEvent.click(screen.getByRole("button", { name: "新建收藏夹" }));
expect(rendered.emitted("create-folder")).toHaveLength(1);
});
it("emits the current draft selection when creating a folder", async () => {
const rendered = render(FavoriteFolderSelector, {
props: {
show: true,
folders: [defaultFolder],
},
});
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
await fireEvent.click(screen.getByRole("button", { name: "新建收藏夹" }));
expect(rendered.emitted("create-folder")?.[0]?.[0]).toEqual([1]);
});
it("emits checked folder ids only after confirmation", async () => {
const rendered = render(FavoriteFolderSelector, {
props: {
show: true,
folders: [
defaultFolder,
{ ...defaultFolder, id: 2, name: "办公收藏", itemCount: 0 },
],
selectedFolderIds: [defaultFolder.id],
},
});
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
await fireEvent.click(screen.getByLabelText("收藏到 办公收藏"));
expect(rendered.emitted("save-selection")).toBeUndefined();
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual([2]);
});
it("emits the fallback default folder selection after confirmation", async () => {
const rendered = render(FavoriteFolderSelector, {
props: {
show: true,
folders: [],
},
});
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual(["default"]);
});
it("preserves unsaved folder checks when the folder list changes", async () => {
const rendered = render(FavoriteFolderSelector, {
props: {
show: true,
folders: [defaultFolder],
selectedFolderIds: [],
},
});
await fireEvent.click(screen.getByLabelText("收藏到 默认收藏夹"));
await rendered.rerender({
folders: [
defaultFolder,
{ ...defaultFolder, id: 2, name: "办公收藏", itemCount: 0 },
],
});
await fireEvent.click(screen.getByLabelText("收藏到 办公收藏"));
await fireEvent.click(screen.getByRole("button", { name: "保存收藏夹" }));
expect(rendered.emitted("save-selection")?.[0]?.[0]).toEqual([1, 2]);
});
});
@@ -37,6 +37,9 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -57,6 +60,9 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -75,6 +81,9 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -97,6 +106,9 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -119,6 +131,9 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
@@ -136,9 +151,99 @@ describe("InstalledAppsModal", () => {
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
expect(screen.queryByRole("button", { name: "查看详情" })).toBeNull();
});
it("requests login for cloud actions when logged out", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: false,
syncing: false,
syncMessage: "",
},
});
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
expect(rendered.emitted("request-login")).toHaveLength(2);
});
it("emits cloud sync and restore events when logged in", async () => {
const rendered = render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true,
syncing: false,
syncMessage: "",
},
});
await fireEvent.click(screen.getByRole("button", { name: "同步到账号" }));
await fireEvent.click(screen.getByRole("button", { name: "从账号恢复" }));
expect(rendered.emitted("sync-to-account")).toHaveLength(1);
expect(rendered.emitted("restore-from-account")).toHaveLength(1);
});
it("disables sync button while syncing", () => {
render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true,
syncing: true,
syncMessage: "",
},
});
expect(screen.getByRole("button", { name: "同步中" })).toBeDisabled();
});
it("shows account sync feedback in the installed apps modal", () => {
render(InstalledAppsModal, {
props: {
show: true,
apps: [],
loading: false,
error: "",
activeOrigin: "spark",
storeFilter: "both",
sparkAvailable: true,
apmAvailable: true,
loggedIn: true,
syncing: false,
syncMessage: "同步完成",
},
});
expect(screen.getByText("同步完成")).toBeTruthy();
});
});
+31
View File
@@ -0,0 +1,31 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import LoginModal from "@/components/LoginModal.vue";
describe("LoginModal", () => {
it("does not show the old password forwarding note", () => {
render(LoginModal, {
props: { show: true, loading: false, error: "" },
});
expect(screen.queryByText(/密码仅直接/)).toBeNull();
});
it("emits login credentials and register request", async () => {
const rendered = render(LoginModal, {
props: { show: true, loading: false, error: "" },
});
await fireEvent.update(screen.getByLabelText("论坛账号"), " momen ");
await fireEvent.update(screen.getByLabelText("论坛密码"), " secret ");
await fireEvent.click(screen.getByRole("button", { name: "登录" }));
await fireEvent.click(screen.getByRole("button", { name: "注册账号" }));
expect(rendered.emitted("login")?.[0]?.[0]).toEqual({
identification: "momen",
password: "secret",
});
expect(rendered.emitted("register")).toHaveLength(1);
});
});
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import ReviewUserProfileModal from "@/components/ReviewUserProfileModal.vue";
import type { ReviewUserProfile } from "@/global/typedefinition";
const profile = (
overrides: Partial<ReviewUserProfile> = {},
): ReviewUserProfile => ({
displayName: "Momen",
...overrides,
});
describe("ReviewUserProfileModal", () => {
it("does not insert unsafe cover URLs into background image styles", () => {
const { container } = render(ReviewUserProfileModal, {
props: {
show: true,
profile: profile({ coverUrl: "javascript:alert(1)" }),
},
});
expect(container.innerHTML).not.toContain("javascript:alert(1)");
const cover = container.querySelector("[data-testid='review-user-cover']");
expect((cover as HTMLElement | null)?.style.backgroundImage).toBe("");
});
it("emits close when Escape is pressed inside the dialog", async () => {
const rendered = render(ReviewUserProfileModal, {
props: {
show: true,
profile: profile(),
},
});
await fireEvent.keyDown(screen.getByRole("dialog", { name: "用户资料" }), {
key: "Escape",
});
expect(rendered.emitted("close")).toHaveLength(1);
});
it("emits the forum profile URL when opening a username profile", async () => {
const rendered = render(ReviewUserProfileModal, {
props: {
show: true,
profile: profile({ username: "momen" }),
},
});
await fireEvent.click(screen.getByRole("button", { name: "查看论坛资料" }));
expect(rendered.emitted("open-forum-profile")?.[0]?.[0]).toMatch(
/\/u\/momen$/,
);
});
});
+672
View File
@@ -0,0 +1,672 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ReviewsPanel from "@/components/ReviewsPanel.vue";
import {
createReviewReply,
deleteReview,
deleteReviewReply,
fetchRatingSummary,
fetchReviews,
likeReview,
likeReviewReply,
submitReview,
} from "@/modules/backendApi";
import type {
AppReview,
AppReviewReply,
RatingSummary,
ReviewTags,
} from "@/global/typedefinition";
const emptySummary: RatingSummary = {
averageRating: 0,
reviewCount: 0,
starCounts: {},
};
vi.mock("@/modules/backendApi", () => ({
createReviewReply: vi.fn(),
deleteReview: vi.fn(),
deleteReviewReply: vi.fn(),
fetchRatingSummary: vi.fn(async () => emptySummary),
fetchReviews: vi.fn(async () => []),
likeReview: vi.fn(),
likeReviewReply: vi.fn(),
submitReview: vi.fn(),
}));
const tags: ReviewTags = {
origin: "apm",
category: "office",
pkgname: "wps",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
};
const makeReview = (overrides: Partial<AppReview>): AppReview => ({
id: 1,
rating: 5,
content: "默认评价",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "星火用户",
userAvatarUrl: "",
likeCount: 0,
likedByCurrentUser: false,
canDelete: false,
isAuthor: false,
isDeleted: false,
replies: [],
...overrides,
});
const makeReply = (overrides: Partial<AppReviewReply>): AppReviewReply => ({
id: 101,
reviewId: 1,
parentId: null,
content: "默认回复",
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "回复用户",
userAvatarUrl: "",
likeCount: 0,
likedByCurrentUser: false,
canDelete: false,
isAuthor: false,
isDeleted: false,
replies: [],
...overrides,
});
describe("ReviewsPanel", () => {
beforeEach(() => {
vi.mocked(fetchRatingSummary).mockReset();
vi.mocked(fetchReviews).mockReset();
vi.mocked(submitReview).mockReset();
vi.mocked(likeReview).mockReset();
vi.mocked(createReviewReply).mockReset();
vi.mocked(deleteReview).mockReset();
vi.mocked(likeReviewReply).mockReset();
vi.mocked(deleteReviewReply).mockReset();
vi.mocked(fetchRatingSummary).mockResolvedValue(emptySummary);
vi.mocked(fetchReviews).mockResolvedValue([]);
vi.mocked(likeReview).mockResolvedValue({
likedByCurrentUser: true,
likeCount: 1,
});
vi.mocked(createReviewReply).mockResolvedValue(makeReply({}));
vi.mocked(deleteReview).mockResolvedValue(undefined);
vi.mocked(likeReviewReply).mockResolvedValue({
likedByCurrentUser: true,
likeCount: 1,
});
vi.mocked(deleteReviewReply).mockResolvedValue(undefined);
});
it("shows anonymous login prompt and read-only review tags", () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: false },
});
expect(screen.getByText("登录后发表评论")).toBeTruthy();
expect(screen.getByText("1.0.0")).toBeTruthy();
expect(screen.getByText("deepin 25")).toBeTruthy();
expect(fetchRatingSummary).not.toHaveBeenCalled();
expect(fetchReviews).not.toHaveBeenCalled();
});
it("hides the submit form when reviews are read-only", () => {
render(ReviewsPanel, {
props: {
appKey: "apm:amd64-apm:office:wps",
tags,
loggedIn: true,
canSubmit: false,
},
});
expect(screen.queryByRole("button", { name: "发表评论" })).toBeNull();
expect(screen.getByText("安装应用后可发表评论。")).toBeTruthy();
});
it("ignores stale review responses after app key changes", async () => {
let resolveFirstSummary!: (summary: RatingSummary) => void;
let resolveFirstReviews!: (reviews: AppReview[]) => void;
let resolveSecondSummary!: (summary: RatingSummary) => void;
let resolveSecondReviews!: (reviews: AppReview[]) => void;
vi.mocked(fetchRatingSummary)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstSummary = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondSummary = resolve;
}),
);
vi.mocked(fetchReviews)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstReviews = resolve;
}),
)
.mockReturnValueOnce(
new Promise((resolve) => {
resolveSecondReviews = resolve;
}),
);
const rendered = render(ReviewsPanel, {
props: { appKey: "first", tags, loggedIn: true },
});
await rendered.rerender({ appKey: "second", tags, loggedIn: true });
resolveSecondSummary({ averageRating: 5, reviewCount: 1, starCounts: {} });
resolveSecondReviews([
{
id: 2,
rating: 5,
content: "second review",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
userDisplayName: "Second User",
userAvatarUrl: "",
},
]);
expect(await screen.findByText("second review")).toBeTruthy();
expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
resolveFirstSummary({ averageRating: 1, reviewCount: 1, starCounts: {} });
resolveFirstReviews([
{
id: 1,
rating: 1,
content: "first review",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
userDisplayName: "First User",
userAvatarUrl: "",
},
]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.getByText("second review")).toBeTruthy();
expect(screen.getByText("5.0 / 5 (1)")).toBeTruthy();
expect(screen.queryByText("first review")).toBeNull();
expect(screen.queryByText("1.0 / 5 (1)")).toBeNull();
});
it("shows a friendly submit error instead of raw network errors", async () => {
vi.mocked(submitReview).mockRejectedValueOnce(new Error("Network Error"));
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
await fireEvent.update(
screen.getByPlaceholderText("分享你的使用体验"),
"好用",
);
await fireEvent.click(screen.getByRole("button", { name: "发表评论" }));
expect(
await screen.findByText("无法连接星火账号服务,请稍后重试。"),
).toBeTruthy();
});
it("shows a re-login prompt when loading reviews with a stale token", async () => {
vi.mocked(fetchRatingSummary).mockRejectedValueOnce(
new Error("Request failed with status code 401"),
);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(
await screen.findByText("登录状态已失效,请重新登录星火账号。"),
).toBeTruthy();
});
it("submits the rating selected by sliding over stars", async () => {
vi.mocked(submitReview).mockResolvedValueOnce(
makeReview({ rating: 3, content: "一般" }),
);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 500,
height: 40,
top: 0,
left: 0,
right: 500,
bottom: 40,
toJSON: () => ({}),
});
const pointerDown = new MouseEvent("pointerdown", {
bubbles: true,
clientX: 250,
});
const pointerMove = new MouseEvent("pointermove", {
bubbles: true,
clientX: 250,
});
const pointerUp = new MouseEvent("pointerup", {
bubbles: true,
clientX: 250,
});
Object.defineProperty(pointerDown, "pointerId", { value: 1 });
Object.defineProperty(pointerMove, "pointerId", { value: 1 });
Object.defineProperty(pointerUp, "pointerId", { value: 1 });
await fireEvent(slider, pointerDown);
await fireEvent(slider, pointerMove);
await fireEvent(slider, pointerUp);
await fireEvent.update(
screen.getByPlaceholderText("分享你的使用体验"),
"一般",
);
await fireEvent.click(screen.getByRole("button", { name: "发表评论" }));
expect(submitReview).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
expect.objectContaining({ rating: 3 }),
);
});
it("updates rating preview from hovering over the star hitbox", async () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
vi.spyOn(slider, "getBoundingClientRect").mockReturnValue({
x: 0,
y: 0,
width: 500,
height: 40,
top: 0,
left: 0,
right: 500,
bottom: 40,
toJSON: () => ({}),
});
const pointerMove = new MouseEvent("pointermove", {
bubbles: true,
clientX: 250,
});
Object.defineProperty(pointerMove, "pointerId", { value: 1 });
await fireEvent(slider, pointerMove);
expect(slider).toHaveAttribute("aria-valuenow", "3");
});
it("does not include a trailing rating label in the star slider hitbox", () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
expect(slider).not.toHaveTextContent("星");
expect(slider).not.toHaveClass("border");
expect(slider).not.toHaveClass("bg-amber-50");
});
it("supports keyboard changes for the sliding star rating", async () => {
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const slider = screen.getByRole("slider", { name: "评分" });
await fireEvent.keyDown(slider, { key: "ArrowLeft" });
await fireEvent.keyDown(slider, { key: "ArrowLeft" });
expect(slider).toHaveAttribute("aria-valuenow", "3");
});
it("filters loaded reviews by package architecture and distro", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
{
id: 11,
rating: 5,
content: "amd64 deepin",
version: tags.version,
packageArch: "amd64",
clientArch: tags.clientArch,
distro: "deepin 25",
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "Deepin User",
userAvatarUrl: "",
},
{
id: 12,
rating: 4,
content: "arm64 gxde",
version: tags.version,
packageArch: "arm64",
clientArch: tags.clientArch,
distro: "GXDE OS 25",
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-20T00:00:00Z",
updatedAt: "2026-05-20T00:00:00Z",
userDisplayName: "GXDE User",
userAvatarUrl: "",
},
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("amd64 deepin")).toBeTruthy();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "arm64" },
});
expect(screen.queryByText("amd64 deepin")).toBeNull();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "" },
});
await fireEvent.change(screen.getByLabelText("按发行版筛选"), {
target: { value: "GXDE OS 25" },
});
expect(screen.queryByText("amd64 deepin")).toBeNull();
expect(screen.getByText("arm64 gxde")).toBeTruthy();
});
it("resets stale review filters when the app key changes", async () => {
vi.mocked(fetchReviews)
.mockResolvedValueOnce([
makeReview({ id: 21, content: "first amd64", packageArch: "amd64" }),
makeReview({
id: 22,
content: "first arm64",
packageArch: "arm64",
distro: "GXDE OS 25",
}),
])
.mockResolvedValueOnce([
makeReview({
id: 23,
content: "second only amd64",
packageArch: "amd64",
}),
]);
const rendered = render(ReviewsPanel, {
props: { appKey: "first", tags, loggedIn: true },
});
expect(await screen.findByText("first amd64")).toBeTruthy();
await fireEvent.change(screen.getByLabelText("按架构筛选"), {
target: { value: "arm64" },
});
expect(screen.queryByText("first amd64")).toBeNull();
expect(screen.getByText("first arm64")).toBeTruthy();
await rendered.rerender({ appKey: "second", tags, loggedIn: true });
expect(await screen.findByText("second only amd64")).toBeTruthy();
expect(screen.queryByText("没有符合筛选条件的评价")).toBeNull();
});
it("exposes reviewer detail affordances from avatar and name buttons", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 31,
content: "用户资料入口",
userDisplayName: "Detail User",
}),
]);
const { emitted } = render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
await fireEvent.click(
await screen.findByRole("button", { name: "查看Detail User的资料" }),
);
expect(screen.getByText("正在查看 Detail User 的资料")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "Detail User" }));
expect(emitted()["show-user"]).toHaveLength(2);
});
it("calls backend review actions and only shows delete from backend metadata", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({ id: 41, content: "普通评价", userDisplayName: "Reader" }),
makeReview({
id: 42,
content: "作者评价",
userDisplayName: "Author",
isAuthor: true,
canDelete: true,
}),
]);
const rendered = render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("普通评价")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "点赞" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "回复" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "删除" })).toHaveLength(1);
await fireEvent.click(screen.getAllByRole("button", { name: "点赞" })[0]);
expect(likeReview).toHaveBeenCalledWith("apm:amd64-apm:office:wps", 41);
expect(fetchReviews).toHaveBeenCalledTimes(2);
expect(await screen.findByText("作者评价")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[0]);
expect(deleteReview).toHaveBeenCalledWith("apm:amd64-apm:office:wps", 42);
await rendered.rerender({
appKey: "apm:amd64-apm:office:wps",
tags,
loggedIn: true,
currentUserIsAdmin: true,
});
expect(screen.getAllByRole("button", { name: "删除" })).toHaveLength(1);
});
it("creates review replies and nested replies through backend APIs", async () => {
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 51,
content: "可回复评价",
replies: [
makeReply({
id: 151,
reviewId: 51,
content: "已有回复",
replies: [
makeReply({
id: 152,
reviewId: 51,
parentId: 151,
content: "已有二级回复",
}),
],
}),
],
}),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
await fireEvent.click(
(await screen.findAllByRole("button", { name: "回复" }))[0],
);
expect(screen.getByText("已有二级回复")).toBeTruthy();
await fireEvent.update(
screen.getByPlaceholderText("写下你的回复"),
"一级回复",
);
await fireEvent.click(screen.getByRole("button", { name: "发送回复" }));
expect(createReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
51,
{ content: "一级回复" },
);
expect(await screen.findByText("已有回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "回复" })[1]);
await fireEvent.update(
screen.getByPlaceholderText("写下你的回复"),
"二级回复",
);
await fireEvent.click(screen.getByRole("button", { name: "发送回复" }));
expect(createReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
51,
{ content: "二级回复", parentId: 151 },
);
});
it("calls backend reply actions and shows permission errors", async () => {
vi.mocked(deleteReview).mockRejectedValueOnce(
new Error("请登录星火账号后重试。"),
);
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({
id: 61,
content: "带回复评价",
canDelete: true,
replies: [
makeReply({
id: 161,
reviewId: 61,
content: "可操作回复",
canDelete: true,
}),
],
}),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("可操作回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "点赞" })[1]);
expect(likeReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
61,
161,
);
expect(await screen.findByText("可操作回复")).toBeTruthy();
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[0]);
expect(await screen.findByText("请登录星火账号后重试。"));
await fireEvent.click(screen.getAllByRole("button", { name: "删除" })[1]);
expect(deleteReviewReply).toHaveBeenCalledWith(
"apm:amd64-apm:office:wps",
61,
161,
);
});
it("preserves stale-token prompts from review action failures", async () => {
vi.mocked(likeReview).mockRejectedValueOnce(
new Error("登录状态已失效,请重新登录星火账号。"),
);
vi.mocked(fetchReviews).mockResolvedValue([
makeReview({ id: 71, content: "旧登录态评价" }),
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
expect(await screen.findByText("旧登录态评价")).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "点赞" }));
expect(
await screen.findByText("登录状态已失效,请重新登录星火账号。"),
).toBeTruthy();
});
it("shows reviewer avatars when available", async () => {
vi.mocked(fetchRatingSummary).mockResolvedValue({
averageRating: 5,
reviewCount: 1,
starCounts: { 5: 1 },
});
vi.mocked(fetchReviews).mockResolvedValue([
{
id: 3,
rating: 5,
content: "头像正常显示",
version: tags.version,
packageArch: tags.packageArch,
clientArch: tags.clientArch,
distro: tags.distro,
origin: tags.origin,
category: tags.category,
createdAt: "2026-05-19T00:00:00Z",
updatedAt: "2026-05-19T00:00:00Z",
userDisplayName: "Avatar User",
userAvatarUrl: "https://bbs.spark-app.store/avatar.png",
},
]);
render(ReviewsPanel, {
props: { appKey: "apm:amd64-apm:office:wps", tags, loggedIn: true },
});
const avatar = await screen.findByAltText("Avatar User 的头像");
expect(avatar).toHaveAttribute(
"src",
"https://bbs.spark-app.store/avatar.png",
);
});
});
@@ -0,0 +1,55 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import UserManagementModal from "@/components/UserManagementModal.vue";
import type { SparkUser } from "@/global/typedefinition";
const user: SparkUser = {
id: 1,
flarumUserId: "42",
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.png",
coverUrl: "https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
forumLevel: "管理员",
forumGroups: ["管理员"],
};
describe("UserManagementModal", () => {
it("renders account management in an independent iframe without token query params", () => {
render(UserManagementModal, {
props: {
show: true,
user,
downloadedApps: [],
syncEnabled: true,
loading: false,
error: "",
},
});
const frame = screen.getByTitle("星火账号用户管理") as HTMLIFrameElement;
expect(frame).toBeTruthy();
expect(frame.src).toContain("account.spark-app.store");
expect(frame.src).toContain("/account");
expect(frame.src).not.toMatch(/token|jwt|password|access/i);
});
it("shows retry controls when iframe reports a load failure", async () => {
render(UserManagementModal, {
props: {
show: true,
user,
downloadedApps: [],
syncEnabled: true,
loading: false,
error: "",
},
});
await fireEvent.error(screen.getByTitle("星火账号用户管理"));
expect(screen.getByText("账号页面加载失败")).toBeTruthy();
expect(screen.getByRole("button", { name: "重试" })).toBeTruthy();
});
});
@@ -0,0 +1,93 @@
import { render, screen } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import UserManagementView from "@/components/UserManagementView.vue";
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
const user: SparkUser = {
id: 1,
flarumUserId: "123",
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.png",
forumLevel: "管理员",
forumGroups: ["管理员"],
};
const download: DownloadedAppRecord = {
id: 1,
appKey: "app:office:wps",
pkgname: "wps",
name: "WPS",
category: "office",
selectedOrigin: "apm",
version: "1.0.0",
packageArch: "amd64",
downloadedAt: "2026-05-18T00:00:00Z",
};
describe("UserManagementView", () => {
it("renders profile, forum level, links, downloads, and sync preference", () => {
render(UserManagementView, {
props: {
user,
downloadedApps: [download],
syncEnabled: true,
loading: false,
error: "",
syncing: false,
syncMessage: "",
},
});
expect(screen.getByText("Momen")).toBeTruthy();
expect(screen.getByText("管理员")).toBeTruthy();
expect(screen.getByText("论坛首页")).toBeTruthy();
expect(screen.getByText("修改论坛资料")).toBeTruthy();
expect(screen.getByText("WPS")).toBeTruthy();
expect(screen.getByLabelText("自动同步已安装应用")).toBeChecked();
});
it("shows manual sync progress and result feedback", async () => {
const { rerender } = render(UserManagementView, {
props: {
user,
downloadedApps: [],
syncEnabled: false,
loading: false,
error: "",
syncing: true,
syncMessage: "",
},
});
expect(screen.getByRole("button", { name: "同步中..." })).toBeDisabled();
await rerender({ syncing: false, syncMessage: "同步完成" });
expect(screen.getByText("同步完成")).toBeTruthy();
});
it("renders the forum profile cover when available", () => {
render(UserManagementView, {
props: {
user: {
...user,
coverUrl:
"https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg",
},
downloadedApps: [],
syncEnabled: false,
loading: false,
error: "",
syncing: false,
syncMessage: "",
},
});
expect(screen.getByTestId("profile-cover")).toHaveStyle({
backgroundImage:
'url("https://bbs.spark-app.store/assets/covers/JizZCVjiSFASrEfp.jpg")',
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { fireEvent, render, screen } from "@testing-library/vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
import WindowTitleBar from "@/components/WindowTitleBar.vue";
const windowControls = {
minimize: vi.fn(),
toggleMaximize: vi.fn(),
close: vi.fn(),
};
describe("WindowTitleBar", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, "windowControls", {
value: windowControls,
configurable: true,
});
});
it("sends window control requests", async () => {
render(WindowTitleBar);
await fireEvent.click(screen.getByRole("button", { name: "最小化" }));
await fireEvent.click(screen.getByRole("button", { name: "最大化或还原" }));
await fireEvent.click(screen.getByRole("button", { name: "关闭" }));
expect(windowControls.minimize).toHaveBeenCalledTimes(1);
expect(windowControls.toggleMaximize).toHaveBeenCalledTimes(1);
expect(windowControls.close).toHaveBeenCalledTimes(1);
});
it("stays below modal overlays in the stacking order", () => {
const { container } = render(WindowTitleBar);
expect(container.firstElementChild?.className).toContain("z-20");
expect(container.firstElementChild?.className).not.toContain("z-[60]");
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { buildAccountFrameUrl } from "@/modules/accountCenterUrl";
describe("accountCenterUrl", () => {
it("falls back to the production account URL when configured URL is malformed", () => {
const url = buildAccountFrameUrl("not a url", "momen");
expect(url).toContain("https://account.spark-app.store/account");
expect(url).toContain("view=management");
expect(url).toContain("user=momen");
expect(url).not.toMatch(/token|jwt|password|access/i);
});
it("falls back when configured URL uses an unsafe protocol", () => {
const url = buildAccountFrameUrl("javascript:alert(1)", "momen");
expect(url).toContain("https://account.spark-app.store/account");
expect(url).toContain("view=management");
expect(url).toContain("user=momen");
expect(url).not.toMatch(/^javascript:/i);
});
});
@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("accountSyncState", () => {
beforeEach(() => {
vi.resetModules();
localStorage.clear();
});
it("scopes installed sync preference to the current user", async () => {
const {
installedSyncEnabled,
loadInstalledSyncPreference,
setInstalledSyncEnabled,
} = await import("@/global/accountSyncState");
loadInstalledSyncPreference(1);
setInstalledSyncEnabled(true);
loadInstalledSyncPreference(2);
expect(installedSyncEnabled.value).toBeNull();
setInstalledSyncEnabled(false);
loadInstalledSyncPreference(1);
expect(installedSyncEnabled.value).toBe(true);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
FLARUM_BASE_URL,
FLARUM_REGISTER_URL,
SPARK_BACKEND_BASE_URL,
} from "@/global/storeConfig";
import type {
DownloadedAppRecord,
FavoriteFolder,
FavoriteItem,
ReviewTags,
SparkUser,
SyncedAppListItem,
} from "@/global/typedefinition";
const productionEnv = readFileSync(
resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env.production"),
"utf-8",
);
describe("account shared types", () => {
it("points production account requests at the public account service", () => {
expect(productionEnv).toContain(
"VITE_SPARK_BACKEND_BASE_URL=https://account.spark-app.store",
);
});
it("exports backend/forum config and account shapes", () => {
const user: SparkUser = {
id: 1,
flarumUserId: "123",
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.png",
coverUrl: "https://bbs.spark-app.store/assets/covers/user.jpg",
forumLevel: "管理员",
forumGroups: ["管理员"],
};
const folder: FavoriteFolder = {
id: 1,
name: "默认收藏夹",
itemCount: 1,
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
};
const favorite: FavoriteItem = {
id: 2,
appKey: "app:office:wps",
pkgname: "wps",
name: "WPS",
category: "office",
iconUrl: "https://example.invalid/wps.png",
createdAt: "2026-05-18T00:00:00Z",
};
const download: DownloadedAppRecord = {
id: 3,
appKey: "app:office:wps",
pkgname: "wps",
name: "WPS",
category: "office",
selectedOrigin: "apm",
version: "1.0.0",
packageArch: "amd64",
downloadedAt: "2026-05-18T00:00:00Z",
};
const syncItem: SyncedAppListItem = {
pkgname: "wps",
origin: "apm",
category: "office",
version: "1.0.0",
packageArch: "amd64",
appName: "WPS",
iconUrl: "https://example.invalid/wps.png",
};
const tags: ReviewTags = {
origin: "apm",
category: "office",
pkgname: "wps",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
};
expect(typeof SPARK_BACKEND_BASE_URL).toBe("string");
expect(SPARK_BACKEND_BASE_URL).toMatch(/^https?:\/\//);
expect(FLARUM_BASE_URL).toContain("bbs.spark-app.store");
expect(FLARUM_REGISTER_URL).toContain("register");
expect(user.forumGroups).toEqual(["管理员"]);
expect(user.coverUrl).toContain("/assets/covers/");
expect(folder.itemCount).toBe(1);
expect(favorite.appKey).toBe("app:office:wps");
expect(download.selectedOrigin).toBe("apm");
expect(syncItem.origin).toBe("apm");
expect(tags.packageArch).toBe("amd64");
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
buildFavoriteAppKey,
buildReviewAppKey,
buildReviewTags,
getDisplayApp,
parsePackageArch,
} from "@/modules/appIdentity";
import type { App } from "@/global/typedefinition";
const app: App = {
name: "WPS",
pkgname: "wps",
version: "1.0.0",
filename: "wps_1.0.0_amd64.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
more: "",
tags: "",
img_urls: [],
icons: "",
category: "office",
origin: "apm",
currentStatus: "not-installed",
};
describe("appIdentity", () => {
it("builds favorite and review keys", () => {
expect(buildFavoriteAppKey(app)).toBe("app:office:wps");
expect(buildReviewAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps");
});
it("builds review keys from already-qualified client arch values", () => {
expect(buildReviewAppKey({ ...app, origin: "spark" }, "amd64-store")).toBe(
"spark:amd64-store:office:wps",
);
expect(buildReviewAppKey(app, "amd64-apm")).toBe(
"apm:amd64-apm:office:wps",
);
});
it("parses package arch and review tags", () => {
expect(parsePackageArch(app.filename)).toBe("amd64");
expect(
buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" }),
).toMatchObject({
origin: "apm",
category: "office",
pkgname: "wps",
packageArch: "amd64",
});
});
it("returns selected display app from merged apps", () => {
const merged: App = {
...app,
isMerged: true,
viewingOrigin: "spark",
sparkApp: { ...app, origin: "spark" },
apmApp: app,
};
expect(getDisplayApp(merged)?.origin).toBe("spark");
});
});
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import {
buildSyncItems,
cloudItemKey,
cloudPackageKey,
mergeInstalledApps,
resolveCloudInstallCandidate,
} from "@/modules/appListSync";
import type { App } from "@/global/typedefinition";
const createApp = (overrides: Partial<App> = {}): App => ({
name: "Spark Notes",
pkgname: "spark-notes",
version: "1.0.0",
filename: "spark-notes_1.0.0_amd64.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "1 MB",
more: "",
tags: "",
img_urls: [],
icons: "https://example.test/icon.png",
category: "office",
origin: "spark",
currentStatus: "installed",
...overrides,
});
describe("appListSync", () => {
it("builds cloud sync items for installed store-recognized user apps", () => {
expect(buildSyncItems([createApp()])).toEqual([
{
pkgname: "spark-notes",
origin: "spark",
category: "office",
version: "1.0.0",
packageArch: "amd64",
appName: "Spark Notes",
iconUrl: "https://example.test/icon.png",
},
]);
});
it("filters out non-installed unknown dependency and unusable package entries", () => {
const items = buildSyncItems([
createApp({ pkgname: "not-installed", currentStatus: "not-installed" }),
createApp({ pkgname: "unknown-app", category: "unknown" }),
createApp({ pkgname: "dependency", isDependency: true }),
createApp({ pkgname: "" }),
createApp({ pkgname: "blank-origin", origin: "spark" }),
createApp({ pkgname: "kept", origin: "apm", arch: "arm64" }),
]);
expect(items).toEqual([
expect.objectContaining({ pkgname: "blank-origin" }),
expect.objectContaining({ pkgname: "kept", packageArch: "arm64" }),
]);
});
it("uses pkgname as appName and blank icon when optional display fields are missing", () => {
const app = createApp({ icons: "", pkgname: "fallback-name" });
app.name = "";
const [syncItem] = buildSyncItems([app]);
expect(syncItem).toMatchObject({
appName: "fallback-name",
iconUrl: "",
});
});
it("builds stable installed keys from origin and package", () => {
expect(cloudItemKey({ origin: "apm", pkgname: "amber-ce" })).toBe(
"apm:amber-ce",
);
});
it("builds origin-agnostic package keys for cross-source restore detection", () => {
expect(cloudPackageKey({ pkgname: "amber-ce" })).toBe("amber-ce");
});
it("merges refreshed apps without mutating active modal origin lists", () => {
const current = [createApp({ origin: "apm", pkgname: "apm-installed" })];
const refreshed = [
createApp({ origin: "spark", pkgname: "spark-installed" }),
];
expect(mergeInstalledApps(current, refreshed, ["spark"])).toEqual([
expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }),
expect.objectContaining({ origin: "spark", pkgname: "spark-installed" }),
]);
expect(current).toEqual([
expect.objectContaining({ origin: "apm", pkgname: "apm-installed" }),
]);
});
it("resolves cloud restore items by exact source before package fallback", () => {
const sparkCloudItem = {
pkgname: "shared-app",
origin: "spark" as const,
category: "office",
version: "1.0.0",
packageArch: "amd64",
appName: "Shared App",
iconUrl: "",
};
const apmCandidate = createApp({
origin: "apm",
pkgname: "shared-app",
category: "office",
});
const sparkCandidate = createApp({
origin: "spark",
pkgname: "shared-app",
category: "office",
});
expect(
resolveCloudInstallCandidate(sparkCloudItem, [
apmCandidate,
sparkCandidate,
]),
).toBe(sparkCandidate);
expect(resolveCloudInstallCandidate(sparkCloudItem, [apmCandidate])).toBe(
apmCandidate,
);
expect(resolveCloudInstallCandidate(sparkCloudItem, [])).toBeNull();
});
it("prefers same-source package fallback when the category changed", () => {
const sparkCloudItem = {
pkgname: "shared-app",
origin: "spark" as const,
category: "legacy-office",
version: "1.0.0",
packageArch: "amd64",
appName: "Shared App",
iconUrl: "",
};
const apmCandidate = createApp({
origin: "apm",
pkgname: "shared-app",
category: "office",
});
const sparkCandidate = createApp({
origin: "spark",
pkgname: "shared-app",
category: "productivity",
});
expect(
resolveCloudInstallCandidate(sparkCloudItem, [
apmCandidate,
sparkCandidate,
]),
).toBe(sparkCandidate);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("authState", () => {
beforeEach(() => {
vi.resetModules();
localStorage.clear();
});
it("persists and clears a backend session", async () => {
const { authSession, currentUser, isLoggedIn, setAuthSession, logout } =
await import("@/global/authState");
setAuthSession({
accessToken: "jwt",
tokenType: "bearer",
user: {
id: 1,
flarumUserId: "123",
username: "momen",
displayName: "Momen",
avatarUrl: "https://bbs.spark-app.store/avatar.png",
coverUrl: "https://bbs.spark-app.store/assets/covers/user.jpg",
forumLevel: "管理员",
forumGroups: ["管理员"],
},
});
expect(authSession.value?.accessToken).toBe("jwt");
expect(currentUser.value?.displayName).toBe("Momen");
expect(currentUser.value?.coverUrl).toContain("/assets/covers/");
expect(isLoggedIn.value).toBe(true);
expect(
JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken,
).toBe("jwt");
logout();
expect(authSession.value).toBeNull();
expect(isLoggedIn.value).toBe(false);
expect(localStorage.getItem("spark-store-auth")).toBeNull();
});
});
+158
View File
@@ -0,0 +1,158 @@
import axios from "axios";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { exchangeFlarumToken, submitReview } from "@/modules/backendApi";
const axiosMocks = vi.hoisted(() => {
const post = vi.fn();
return {
instance: {
defaults: { headers: { common: {} as Record<string, unknown> } },
get: vi.fn(),
post,
},
post,
};
});
const loggerMocks = vi.hoisted(() => ({
error: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
create: vi.fn(() => axiosMocks.instance),
isAxiosError: (error: unknown) =>
Boolean((error as { isAxiosError?: boolean }).isAxiosError),
},
}));
vi.mock("pino", () => ({
default: () => loggerMocks,
}));
describe("backend API auth exchange", () => {
beforeEach(() => {
vi.mocked(axios.create).mockClear();
axiosMocks.post.mockReset();
loggerMocks.error.mockReset();
});
it("maps backend connection failures to a user-actionable login error", async () => {
const error = Object.assign(new Error("Network Error"), {
code: "ERR_NETWORK",
isAxiosError: true,
request: {},
});
axiosMocks.post.mockRejectedValue(error);
await expect(
exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }),
).rejects.toThrow("无法连接星火账号服务,请确认后端服务已启动或稍后重试。");
expect(loggerMocks.error).toHaveBeenCalledWith(
{
code: "ERR_NETWORK",
message: "Network Error",
status: undefined,
},
"Spark backend auth exchange failed",
);
expect(JSON.stringify(loggerMocks.error.mock.calls)).not.toContain(
"forum-token",
);
});
it("maps backend server failures to an update-required login error", async () => {
const error = Object.assign(
new Error("Request failed with status code 500"),
{
isAxiosError: true,
response: { status: 500 },
},
);
axiosMocks.post.mockRejectedValue(error);
await expect(
exchangeFlarumToken({ flarumUserId: "42", flarumToken: "forum-token" }),
).rejects.toThrow("星火账号服务异常,请确认后端数据库迁移已执行后重试。");
});
it("maps review submission connection failures to a friendly error", async () => {
const error = Object.assign(new Error("Network Error"), {
code: "ERR_NETWORK",
isAxiosError: true,
request: {},
});
axiosMocks.post.mockRejectedValue(error);
await expect(
submitReview("apm:amd64-apm:office:wps", {
rating: 5,
content: "好用",
tags: {
origin: "apm",
category: "office",
pkgname: "wps",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
},
}),
).rejects.toThrow("无法连接星火账号服务,请稍后重试。");
});
it("maps review submission server failures separately from connection failures", async () => {
const error = Object.assign(
new Error("Request failed with status code 500"),
{
isAxiosError: true,
response: { status: 500 },
},
);
axiosMocks.post.mockRejectedValue(error);
await expect(
submitReview("apm:amd64-apm:office:wps", {
rating: 5,
content: "好用",
tags: {
origin: "apm",
category: "office",
pkgname: "wps",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
},
}),
).rejects.toThrow("星火账号服务异常,请稍后重试。");
});
it("maps review submission 401 responses to a re-login prompt", async () => {
const error = Object.assign(
new Error("Request failed with status code 401"),
{
isAxiosError: true,
response: { status: 401 },
},
);
axiosMocks.post.mockRejectedValue(error);
await expect(
submitReview("apm:amd64-apm:office:wps", {
rating: 5,
content: "好用",
tags: {
origin: "apm",
category: "office",
pkgname: "wps",
version: "1.0.0",
packageArch: "amd64",
clientArch: "amd64",
distro: "deepin 25",
},
}),
).rejects.toThrow("登录状态已失效,请重新登录星火账号。");
});
});
@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveFavoriteItems } from "@/modules/favoriteAvailability";
import { loadPriorityConfig } from "@/global/storeConfig";
import type { App, FavoriteItem } from "@/global/typedefinition";
const originalFetch = globalThis.fetch;
const app = (origin: "spark" | "apm", overrides: Partial<App> = {}): App => ({
name: "WPS",
pkgname: "wps",
version: "1.0.0",
filename: "wps_1.0.0_amd64.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
more: "",
tags: "",
img_urls: [],
icons: "",
category: "office",
origin,
currentStatus: "not-installed",
arch: "amd64",
...overrides,
});
const favorite: FavoriteItem = {
id: 1,
appKey: "app:office:wps",
pkgname: "wps",
name: "WPS",
category: "office",
iconUrl: "",
createdAt: "2026-05-18T00:00:00Z",
};
describe("favoriteAvailability", () => {
afterEach(async () => {
vi.restoreAllMocks();
globalThis.fetch = originalFetch;
vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false } as Response);
await loadPriorityConfig("amd64");
vi.restoreAllMocks();
});
it("marks downlisted favorites", () => {
expect(
resolveFavoriteItems(
[favorite],
[],
[],
{ spark: true, apm: true },
"both",
)[0].status,
).toBe("downlisted");
});
it("selects preferred installable variant", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
sparkPriority: { pkgnames: [], categories: [], tags: [] },
apmPriority: { pkgnames: [], categories: [], tags: [] },
}),
} as Response);
await loadPriorityConfig("amd64");
const resolved = resolveFavoriteItems(
[favorite],
[app("spark"), app("apm")],
[],
{ spark: true, apm: true },
"both",
)[0];
expect(resolved.status).toBe("installable");
expect(resolved.selectedApp?.origin).toBe("apm");
});
it("selects Spark when hybrid priority config prefers Spark", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
sparkPriority: { pkgnames: ["wps"], categories: [], tags: [] },
apmPriority: { pkgnames: [], categories: [], tags: [] },
}),
} as Response);
await loadPriorityConfig("amd64");
const resolved = resolveFavoriteItems(
[favorite],
[app("spark"), app("apm")],
[],
{ spark: true, apm: true },
"both",
)[0];
expect(resolved.status).toBe("installable");
expect(resolved.selectedApp?.origin).toBe("spark");
});
it("marks installed favorites", () => {
const resolved = resolveFavoriteItems(
[favorite],
[app("apm")],
[app("apm", { currentStatus: "installed" })],
{ spark: true, apm: true },
"both",
)[0];
expect(resolved.status).toBe("installed");
});
it("marks installed favorites from all-category catalog matches", () => {
const resolved = resolveFavoriteItems(
[favorite],
[app("spark")],
[app("spark", { category: "all", currentStatus: "installed" })],
{ spark: true, apm: true },
"both",
)[0];
expect(resolved.status).toBe("installed");
});
});
+60
View File
@@ -0,0 +1,60 @@
import axios from "axios";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requestFlarumToken } from "@/modules/flarumAuth";
vi.mock("axios", () => ({
default: {
post: vi.fn(),
},
}));
describe("requestFlarumToken", () => {
beforeEach(() => {
vi.mocked(window.ipcRenderer.invoke).mockReset();
vi.mocked(axios.post).mockReset();
});
it("requests the Flarum token through main-process IPC", async () => {
vi.mocked(window.ipcRenderer.invoke).mockResolvedValue({
token: "forum-token",
user_id: 42,
});
const payload = { identification: "user@example.com", password: "secret" };
const token = await requestFlarumToken(payload);
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith(
"request-flarum-token",
payload,
);
expect(axios.post).not.toHaveBeenCalled();
expect(token).toEqual({ token: "forum-token", userId: "42" });
});
it("rejects malformed token responses from main-process IPC", async () => {
vi.mocked(window.ipcRenderer.invoke).mockResolvedValue({
token: "",
user_id: 42,
});
await expect(
requestFlarumToken({ identification: "momen", password: "secret" }),
).rejects.toThrow("论坛登录响应异常,请稍后重试。");
});
it("strips Electron IPC wrapper text from known login errors", async () => {
vi.mocked(window.ipcRenderer.invoke).mockRejectedValue(
new Error(
"Error invoking remote method 'request-flarum-token': Error: 无法连接星火论坛,请检查网络后重试。",
),
);
await expect(
requestFlarumToken({ identification: "momen", password: "secret" }),
).rejects.toMatchObject({
message: "无法连接星火论坛,请检查网络后重试。",
});
});
});
+40
View File
@@ -107,4 +107,44 @@ describe("processInstall queue forwarding", () => {
expect.stringContaining('"id":5'),
);
});
it("returns queued download metadata for account records", async () => {
vi.doMock("axios", () => ({
default: {
create: vi.fn(() => ({
post: vi.fn(() => Promise.resolve({ data: { ok: true } })),
})),
},
}));
Object.assign(window.ipcRenderer, {
on: vi.fn(),
send: vi.fn(),
invoke: vi.fn(() => Promise.resolve(true)),
});
window.apm_store.arch = "amd64";
const { handleInstall } = await import("@/modules/processInstall");
const result = await handleInstall({
name: "WPS",
pkgname: "wps",
version: "1.0.0",
filename: "wps_1.0.0_amd64.deb",
torrent_address: "",
author: "",
contributor: "",
website: "",
update: "",
size: "",
more: "",
tags: "",
img_urls: [],
icons: "",
category: "office",
origin: "apm",
currentStatus: "not-installed",
});
expect(result?.pkgname).toBe("wps");
expect(result?.origin).toBe("apm");
});
});
@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const testDir = dirname(fileURLToPath(import.meta.url));
const mainSource = readFileSync(
resolve(testDir, "../../../electron/main/index.ts"),
"utf-8",
);
const preloadSource = readFileSync(
resolve(testDir, "../../../electron/preload/index.ts"),
"utf-8",
);
const viteEnvSource = readFileSync(
resolve(testDir, "../../vite-env.d.ts"),
"utf-8",
);
describe("frameless window shell config", () => {
it("creates the main BrowserWindow without a native frame", () => {
expect(mainSource).toMatch(/new BrowserWindow\(\{[\s\S]*frame:\s*false/);
});
it("routes titlebar close through the guarded BrowserWindow close path", () => {
expect(mainSource).toContain('ipcMain.on("window-control-close"');
expect(mainSource).toMatch(
/ipcMain\.on\("window-control-close"[\s\S]*win\?\.close\(\)/,
);
expect(mainSource).not.toMatch(
/ipcMain\.on\("window-control-close"[\s\S]*win\?\.destroy\(\)/,
);
});
it("exposes typed window controls from preload", () => {
expect(preloadSource).toContain('exposeInMainWorld("windowControls"');
expect(viteEnvSource).toContain("windowControls: WindowControlBridge");
});
});
+3 -4
View File
@@ -3,9 +3,9 @@
@theme {
--font-sans: "Inter", "system-ui", "-apple-system", "Segoe UI", "sans-serif";
--color-brand: #2563eb;
--color-brand-dark: #1d4ed8;
--color-brand-soft: #60a5fa;
--color-brand: #0071e3;
--color-brand-dark: #0066cc;
--color-brand-soft: #409cff;
--color-surface-light: #f5f7fb;
--color-surface-dark: #0b1220;
@@ -76,4 +76,3 @@
border-radius: 0;
}
}
+83
View File
@@ -0,0 +1,83 @@
<template>
<div
class="absolute left-0 right-0 top-full z-20 mt-2 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900"
>
<button
type="button"
class="quick-menu-item"
@click="emit('open-user-management')"
>
<i class="fas fa-user-cog"></i>
<span class="min-w-0 truncate">用户管理</span>
</button>
<button
type="button"
class="quick-menu-item"
@click="emit('open-favorites')"
>
<i class="fas fa-heart"></i>
<span class="min-w-0 truncate">我的收藏</span>
</button>
<button type="button" class="quick-menu-item" @click="emit('open-forum')">
<i class="fas fa-comments"></i>
<span class="min-w-0 truncate">论坛首页</span>
</button>
<button type="button" class="quick-menu-item" @click="emit('edit-profile')">
<i class="fas fa-id-card"></i>
<span class="min-w-0 truncate">修改论坛资料</span>
</button>
<button
type="button"
class="quick-menu-item text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
@click="emit('logout')"
>
<i class="fas fa-sign-out-alt"></i>
<span class="min-w-0 truncate">退出登录</span>
</button>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
"open-user-management": [];
"open-favorites": [];
"open-forum": [];
"edit-profile": [];
logout: [];
}>();
</script>
<style scoped>
.quick-menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 0.75rem;
min-width: 0;
border-radius: 0.75rem;
padding: 0.625rem 0.75rem;
text-align: left;
font-size: 0.875rem;
font-weight: 500;
color: #475569;
transition: all 0.15s ease;
}
.quick-menu-item i {
flex-shrink: 0;
}
.quick-menu-item:hover {
background: rgba(0, 113, 227, 0.06);
color: #0071e3;
}
.dark .quick-menu-item {
color: #cbd5e1;
}
.dark .quick-menu-item:hover {
background: rgba(64, 156, 255, 0.1);
color: #409cff;
}
</style>
-3
View File
@@ -21,16 +21,13 @@
>
{{ app.name || "" }}
</div>
<!-- 来源标识 -->
<div class="flex shrink-0 gap-1">
<!-- 合并标识两个来源都有时显示 -->
<span
v-if="showMergedBadge"
class="rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider shadow-sm bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400"
>
SPARK/APM
</span>
<!-- 单独标识 -->
<template v-else>
<span
v-if="showSparkBadge"
+123 -18
View File
@@ -15,23 +15,24 @@
@wheel="onOverlayWheel"
>
<div
class="modal-panel relative w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
class="modal-panel relative flex w-full max-w-5xl max-h-[85vh] overflow-y-auto overscroll-contain scrollbar-nowidth rounded-3xl border border-white/10 bg-white/95 px-6 pb-6 shadow-2xl dark:border-slate-800 dark:bg-slate-900 lg:max-h-[85vh] lg:overflow-hidden lg:pb-0"
>
<!-- 返回按钮 - sticky定位在模态框内部左上角滚动时始终可见 -->
<button
type="button"
class="sticky top-2 left-0 z-10 inline-flex items-center gap-2 rounded-full border border-slate-200/70 bg-white/90 px-4 py-2 text-sm font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800/90 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 mt-4"
@click="closeModal"
aria-label="返回"
>
<i class="fas fa-arrow-left"></i>
<span>返回</span>
</button>
<!-- 主布局左侧信息 + 右侧内容 -->
<div class="flex flex-col lg:flex-row gap-6">
<div class="flex w-full flex-col gap-6 lg:min-h-0 lg:flex-row">
<!-- 左侧图标版本来源按钮元信息 -->
<div class="w-full lg:w-72 flex-shrink-0 space-y-5">
<div
data-testid="detail-fixed-sidebar"
class="w-full flex-shrink-0 space-y-5 lg:w-72 lg:self-start lg:py-4"
>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-slate-200/70 bg-white/90 px-4 py-2 text-sm font-medium text-slate-600 shadow-lg backdrop-blur-sm transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800/90 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200"
@click="closeModal"
aria-label="返回"
>
<i class="fas fa-arrow-left"></i>
<span>返回</span>
</button>
<!-- 应用图标和名称 -->
<div class="text-center">
<div
@@ -102,7 +103,7 @@
? 'bg-orange-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="viewingOrigin = 'spark'"
@click="selectOrigin('spark')"
>
Spark
</button>
@@ -115,7 +116,7 @@
? 'bg-blue-500 text-white'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
"
@click="viewingOrigin = 'apm'"
@click="selectOrigin('apm')"
>
APM
</button>
@@ -179,6 +180,14 @@
</button>
</div>
</template>
<button
type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="handleFavorite"
>
<i class="fas fa-star text-xs"></i>
<span>{{ favoriteButtonText }}</span>
</button>
</div>
<!-- 其他元信息 -->
@@ -261,7 +270,10 @@
</div>
<!-- 右侧应用详情+ 截图 -->
<div class="flex-1 min-w-0 space-y-5">
<div
data-testid="detail-scroll-content"
class="min-w-0 flex-1 space-y-5 lg:max-h-[85vh] lg:overflow-y-auto lg:overscroll-contain lg:py-4 lg:pr-2"
>
<!-- 应用详情 -->
<div
v-if="displayApp?.more && displayApp.more.trim() !== ''"
@@ -312,6 +324,51 @@
>
<p class="text-sm text-slate-400">暂无应用截图</p>
</div>
<ReviewsPanel
v-if="loggedIn && activeReviewAppKey && activeReviewTags"
:app-key="activeReviewAppKey"
:tags="activeReviewTags"
:logged-in="loggedIn"
:can-submit="isinstalled"
@request-login="$emit('request-login', $event)"
@show-user="emit('show-user', $event)"
/>
<section
v-else-if="!loggedIn && reviewAppKey && reviewTags"
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<h3
class="text-base font-semibold text-slate-900 dark:text-white mb-3 flex items-center gap-2"
>
<i class="fas fa-comments text-slate-400"></i>
应用评价
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
登录星火账号后可查看评价并发表评论
</p>
<button
type="button"
class="mt-4 inline-flex items-center rounded-xl bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
@click="emit('request-login', '登录后查看和发表评论。')"
>
登录后查看评价
</button>
</section>
<section
v-else-if="reviewAppKey && reviewTags"
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<h3
class="text-base font-semibold text-slate-900 dark:text-white mb-3 flex items-center gap-2"
>
<i class="fas fa-comments text-slate-400"></i>
应用评价
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
安装应用后可发表评论
</p>
</section>
</div>
</div>
</div>
@@ -439,12 +496,14 @@
<script setup lang="ts">
import { computed, useAttrs, ref, watch } from "vue";
import axios from "axios";
import ReviewsPanel from "@/components/ReviewsPanel.vue";
import { useInstallFeedback, downloads } from "../global/downloadStatus";
import {
APM_STORE_BASE_URL,
getHybridDefaultOrigin,
} from "../global/storeConfig";
import type { App } from "../global/typedefinition";
import { buildReviewAppKey, buildReviewTags } from "../modules/appIdentity";
import type { App, AppReview, ReviewTags } from "../global/typedefinition";
const attrs = useAttrs();
@@ -454,15 +513,24 @@ const props = defineProps<{
screenshots: string[];
sparkInstalled: boolean;
apmInstalled: boolean;
loggedIn: boolean;
reviewAppKey: string;
reviewTags: ReviewTags | null;
favorited?: boolean;
favoriteFolderName?: string;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "install", app: App): void;
(e: "remove", app: App): void;
(e: "favorite", app: App): void;
(e: "request-login", message: string): void;
(e: "open-preview", index: number): void;
(e: "open-app", pkgname: string, origin?: "spark" | "apm"): void;
(e: "check-install", app: App): void;
(e: "select-origin", origin: "spark" | "apm"): void;
(e: "show-user", review: AppReview): void;
}>();
const appPkgname = computed(() => props.app?.pkgname);
@@ -576,6 +644,29 @@ const iconPath = computed(() => {
return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
});
const activeReviewAppKey = computed(() => {
if (!displayApp.value) return "";
return buildReviewAppKey(
displayApp.value,
props.reviewTags?.clientArch ?? "amd64",
);
});
const activeReviewTags = computed<ReviewTags | null>(() => {
if (!displayApp.value || !props.reviewTags) return null;
return buildReviewTags(displayApp.value, {
clientArch: props.reviewTags.clientArch,
distro: props.reviewTags.distro,
});
});
const favoriteButtonText = computed(() => {
if (!props.favorited) return "收藏";
return props.favoriteFolderName
? `已收藏 · ${props.favoriteFolderName}`
: "已收藏";
});
const downloadCount = ref<string>("");
// app app
@@ -620,6 +711,20 @@ const handleRemove = () => {
}
};
const handleFavorite = () => {
if (!displayApp.value) return;
if (!props.loggedIn) {
emit("request-login", "收藏应用需要登录星火账号。");
return;
}
emit("favorite", displayApp.value);
};
const selectOrigin = (origin: "spark" | "apm") => {
viewingOrigin.value = origin;
emit("select-origin", origin);
};
const openPreview = (index: number) => {
emit("open-preview", index);
};
+384
View File
@@ -0,0 +1,384 @@
<template>
<section
v-if="displayApp"
class="mx-auto max-w-6xl rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 lg:p-6"
>
<button
type="button"
class="mb-5 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
aria-label="返回"
@click="emit('back')"
>
<i class="fas fa-arrow-left"></i>
<span>返回</span>
</button>
<div class="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
<aside class="space-y-5">
<div class="text-center">
<div
class="mx-auto flex h-28 w-28 items-center justify-center overflow-hidden rounded-3xl bg-gradient-to-b from-slate-100 to-slate-200 shadow-lg dark:from-slate-800 dark:to-slate-700"
>
<img
:src="iconPath"
alt="icon"
class="h-full w-full object-cover"
loading="lazy"
/>
</div>
<h1 class="mt-4 text-2xl font-bold text-slate-900 dark:text-white">
{{ displayApp.name }}
</h1>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ displayApp.pkgname }}
</p>
<p
v-if="displayApp.version"
class="mt-1 text-sm text-slate-500 dark:text-slate-400"
>
{{ displayApp.version }}
</p>
</div>
<div
class="flex items-center justify-between rounded-2xl border border-slate-200/60 bg-slate-50/70 px-4 py-3 dark:border-slate-800/60 dark:bg-slate-800/50"
>
<span class="text-sm text-slate-500 dark:text-slate-400">来源</span>
<div
v-if="app.isMerged"
class="flex overflow-hidden rounded-lg border border-slate-200 shadow-sm dark:border-slate-700"
>
<button
v-if="app.sparkApp"
type="button"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class="
viewingOrigin === 'spark'
? 'bg-orange-500 text-white'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'
"
@click="selectOrigin('spark')"
>
Spark
</button>
<button
v-if="app.apmApp"
type="button"
class="px-3 py-1 text-xs font-medium uppercase tracking-wider transition-colors"
:class="
viewingOrigin === 'apm'
? 'bg-blue-500 text-white'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:hover:bg-slate-600'
"
@click="selectOrigin('apm')"
>
APM
</button>
</div>
<span
v-else
class="rounded-md px-2 py-1 text-xs font-bold uppercase tracking-wider"
:class="
displayApp.origin === 'spark'
? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
"
>
{{ displayApp.origin === "spark" ? "Spark" : "APM" }}
</span>
</div>
<div class="space-y-2">
<button
v-if="!isInstalled"
type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 disabled:opacity-40 dark:bg-slate-700 dark:hover:bg-slate-600"
:disabled="isOtherVersionInstalled"
@click="emit('install', displayApp)"
>
<i class="fas fa-download text-xs"></i>
<span>{{ installButtonText }}</span>
</button>
<div v-else class="flex gap-2">
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl bg-slate-800 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
@click="emit('open-app', displayApp.pkgname, displayApp.origin)"
>
<i class="fas fa-external-link-alt text-xs"></i>
<span>打开</span>
</button>
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:border-rose-400 hover:bg-rose-50 hover:text-rose-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:hover:text-rose-400"
@click="emit('remove', displayApp)"
>
<i class="fas fa-trash text-xs"></i>
<span>卸载</span>
</button>
</div>
<button
type="button"
class="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="handleFavorite"
>
<i class="fas fa-star text-xs"></i>
<span>{{ favoriteButtonText }}</span>
</button>
</div>
<dl
class="space-y-2 border-t border-slate-200/60 pt-3 text-xs dark:border-slate-800/60"
>
<div v-if="displayApp.category" class="flex justify-between gap-3">
<dt class="text-slate-400">分类</dt>
<dd class="truncate text-slate-700 dark:text-slate-300">
{{ displayApp.category }}
</dd>
</div>
<div v-if="displayApp.author" class="flex justify-between gap-3">
<dt class="text-slate-400">作者</dt>
<dd class="truncate text-slate-700 dark:text-slate-300">
{{ displayApp.author }}
</dd>
</div>
<div v-if="displayApp.size" class="flex justify-between gap-3">
<dt class="text-slate-400">大小</dt>
<dd class="text-slate-700 dark:text-slate-300">
{{ displayApp.size }}
</dd>
</div>
<div v-if="displayApp.update" class="flex justify-between gap-3">
<dt class="text-slate-400">更新</dt>
<dd class="text-slate-700 dark:text-slate-300">
{{ displayApp.update }}
</dd>
</div>
</dl>
</aside>
<div class="min-w-0 space-y-5">
<div
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<h2 class="mb-3 flex items-center gap-2 text-base font-semibold">
<i class="fas fa-info-circle text-slate-400"></i>
应用详情
</h2>
<div
v-if="displayApp.more.trim() !== ''"
class="space-y-2 text-sm leading-relaxed text-slate-600 dark:text-slate-300"
v-html="detailHtml"
></div>
<p v-else class="text-sm text-slate-400">暂无应用详情</p>
</div>
<div
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<h2 class="mb-3 flex items-center gap-2 text-base font-semibold">
<i class="fas fa-images text-slate-400"></i>
应用截图
</h2>
<div v-if="screenshots.length" class="grid gap-3 sm:grid-cols-2">
<img
v-for="(screen, index) in screenshots"
:key="screen"
:src="screen"
alt="screenshot"
class="h-44 w-full cursor-pointer rounded-2xl border border-slate-200/60 object-cover shadow-sm transition hover:-translate-y-1 hover:shadow-lg dark:border-slate-800/60"
loading="lazy"
@click="emit('open-preview', index)"
@error="hideImage"
/>
</div>
<p v-else class="text-sm text-slate-400">暂无应用截图</p>
</div>
<ReviewsPanel
v-if="loggedIn && reviewAppKey && reviewTags"
:app-key="reviewAppKey"
:tags="reviewTags"
:logged-in="loggedIn"
:can-submit="isInstalled"
@request-login="$emit('request-login', $event)"
@show-user="emit('show-user', $event)"
/>
<section
v-else-if="!loggedIn && reviewAppKey && reviewTags"
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<h2 class="mb-2 flex items-center gap-2 text-base font-semibold">
<i class="fas fa-comments text-slate-400"></i>
应用评价
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
登录星火账号后可查看评价并发表评论
</p>
<button
type="button"
class="mt-4 inline-flex items-center rounded-xl bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
@click="emit('request-login', '登录后查看和发表评论。')"
>
登录后查看评价
</button>
</section>
<section
v-else-if="reviewAppKey && reviewTags"
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<h2 class="mb-2 flex items-center gap-2 text-base font-semibold">
<i class="fas fa-comments text-slate-400"></i>
应用评价
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
安装应用后可发表评论
</p>
</section>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import ReviewsPanel from "@/components/ReviewsPanel.vue";
import {
APM_STORE_BASE_URL,
getHybridDefaultOrigin,
} from "@/global/storeConfig";
import {
buildReviewAppKey,
buildReviewTags,
getDisplayApp,
} from "@/modules/appIdentity";
import type { App, AppReview, ReviewTags } from "@/global/typedefinition";
const props = defineProps<{
app: App;
screenshots: string[];
sparkInstalled: boolean;
apmInstalled: boolean;
loggedIn: boolean;
reviewAppKey: string;
reviewTags: ReviewTags | null;
favorited?: boolean;
favoriteFolderName?: string;
}>();
const emit = defineEmits<{
back: [];
install: [app: App];
remove: [app: App];
favorite: [app: App];
"request-login": [message: string];
"open-preview": [index: number];
"open-app": [pkgname: string, origin?: "spark" | "apm"];
"check-install": [app: App];
"select-origin": [origin: "spark" | "apm"];
"show-user": [review: AppReview];
}>();
const viewingOrigin = ref<"spark" | "apm">(
props.app.viewingOrigin ?? props.app.origin,
);
watch(
() => props.app,
(app) => {
if (app.isMerged) {
viewingOrigin.value =
app.viewingOrigin ??
(app.sparkApp ? getHybridDefaultOrigin(app.sparkApp) : "apm");
} else {
viewingOrigin.value = app.origin;
}
},
{ immediate: true },
);
const appWithSelectedOrigin = computed<App>(() => ({
...props.app,
viewingOrigin: viewingOrigin.value,
}));
const displayApp = computed(() => getDisplayApp(appWithSelectedOrigin.value));
watch(
() => displayApp.value,
(app) => {
if (app) emit("check-install", app);
},
);
const isInstalled = computed(() =>
viewingOrigin.value === "spark" ? props.sparkInstalled : props.apmInstalled,
);
const isOtherVersionInstalled = computed(() =>
viewingOrigin.value === "spark" ? props.apmInstalled : props.sparkInstalled,
);
const installButtonText = computed(() => {
if (isOtherVersionInstalled.value) {
return viewingOrigin.value === "spark"
? "已安装 APM 版"
: "已安装 Spark 版";
}
return "安装";
});
const iconPath = computed(() => {
if (!displayApp.value) return "";
const arch = window.apm_store.arch || "amd64";
const finalArch =
displayApp.value.origin === "spark" ? `${arch}-store` : `${arch}-apm`;
return `${APM_STORE_BASE_URL}/${finalArch}/${displayApp.value.category}/${displayApp.value.pkgname}/icon.png`;
});
const detailHtml = computed(
() => displayApp.value?.more.replace(/\n/g, "<br>") ?? "",
);
const reviewAppKey = computed(() => {
if (!displayApp.value) return "";
return buildReviewAppKey(
displayApp.value,
props.reviewTags?.clientArch ?? "amd64",
);
});
const reviewTags = computed<ReviewTags | null>(() => {
if (!displayApp.value || !props.reviewTags) return null;
return buildReviewTags(displayApp.value, {
clientArch: props.reviewTags.clientArch,
distro: props.reviewTags.distro,
});
});
const favoriteButtonText = computed(() => {
if (!props.favorited) return "收藏";
return props.favoriteFolderName
? `已收藏 · ${props.favoriteFolderName}`
: "已收藏";
});
const selectOrigin = (origin: "spark" | "apm") => {
viewingOrigin.value = origin;
emit("select-origin", origin);
if (displayApp.value) emit("check-install", displayApp.value);
};
const handleFavorite = () => {
if (!displayApp.value) return;
if (!props.loggedIn) {
emit("request-login", "收藏应用需要登录星火账号。");
return;
}
emit("favorite", displayApp.value);
};
const hideImage = (event: Event) => {
(event.target as HTMLElement).style.display = "none";
};
</script>
+1 -8
View File
@@ -51,13 +51,6 @@
</button>
</div>
</div>
<div
v-if="activeCategory !== 'home'"
class="text-sm text-slate-500 dark:text-slate-400"
id="currentCount"
>
<!-- {{ appsCount }} 个应用 -->
</div>
</div>
</template>
@@ -66,7 +59,7 @@ import { ref, watch } from "vue";
const props = defineProps<{
searchQuery: string;
activeCategory: string;
activeTab: string;
appsCount: number;
}>();
const emit = defineEmits<{
+195
View File
@@ -0,0 +1,195 @@
<template>
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/70 px-4 py-10"
@click.self="emit('close')"
>
<div
class="flex w-full max-w-3xl max-h-[85vh] flex-col rounded-3xl border border-white/10 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex items-start justify-between border-b border-slate-200/70 p-6 dark:border-slate-800/70"
>
<div>
<p class="text-2xl font-semibold text-slate-900 dark:text-white">
从账号恢复
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
选择云端已同步的应用加入安装队列
</p>
</div>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:hover:bg-slate-800 dark:hover:text-white"
aria-label="关闭"
@click="emit('close')"
>
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="flex-1 overflow-y-auto p-6">
<div
v-if="loading"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
>
正在读取云端应用列表
</div>
<div
v-else-if="error"
class="rounded-2xl border border-rose-200/70 bg-rose-50/60 px-4 py-6 text-center text-sm text-rose-600 dark:border-rose-500/40 dark:bg-rose-500/10"
>
{{ error }}
</div>
<div
v-else-if="items.length === 0"
class="rounded-2xl border border-slate-200/70 px-4 py-10 text-center text-slate-500 dark:border-slate-800/70 dark:text-slate-400"
>
云端暂无已同步应用
</div>
<div v-else class="space-y-3">
<label
v-for="item in items"
:key="cloudItemKey(item)"
class="flex items-center gap-3 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-sm dark:border-slate-800/70 dark:bg-slate-900/70"
:class="isInstalled(item) ? 'opacity-60' : 'cursor-pointer'"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-brand focus:ring-brand"
:aria-label="item.appName || item.pkgname"
:checked="selectedKeys.has(cloudItemKey(item))"
:disabled="isInstalled(item)"
@change="toggleSelection(item)"
/>
<img
v-if="item.iconUrl"
:src="item.iconUrl"
class="h-10 w-10 rounded-xl object-contain"
alt=""
/>
<div
v-else
class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-400 dark:bg-slate-800"
>
<i class="fas fa-cube"></i>
</div>
<div class="min-w-0 flex-1">
<p class="font-semibold text-slate-900 dark:text-white">
{{ item.appName || item.pkgname }}
</p>
<p class="truncate text-xs text-slate-500 dark:text-slate-400">
{{ item.origin }} · {{ item.category }} · {{ item.pkgname }} ·
{{ item.version }} · {{ item.packageArch }}
</p>
</div>
<span
v-if="isInstalled(item)"
class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300"
>
已安装
</span>
</label>
</div>
</div>
<div
class="flex items-center justify-end gap-3 border-t border-slate-200/70 p-6 dark:border-slate-800/70"
>
<button
type="button"
class="rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="emit('close')"
>
取消
</button>
<button
type="button"
class="rounded-2xl bg-brand px-5 py-2 text-sm font-semibold text-white transition hover:bg-brand/90 disabled:opacity-40"
:disabled="selectedItems.length === 0"
@click="emit('install-selected', selectedItems)"
>
加入安装队列
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { SyncedAppListItem } from "@/global/typedefinition";
import { cloudItemKey, cloudPackageKey } from "@/modules/appListSync";
const props = defineProps<{
show: boolean;
loading: boolean;
error: string;
items: SyncedAppListItem[];
installedKeys: Set<string>;
installedPackageKeys?: Set<string>;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "install-selected", items: SyncedAppListItem[]): void;
}>();
const selectedKeys = ref<Set<string>>(new Set());
const isInstalled = (item: SyncedAppListItem): boolean =>
props.installedKeys.has(cloudItemKey(item)) ||
Boolean(props.installedPackageKeys?.has(cloudPackageKey(item)));
const selectedItems = computed(() =>
props.items.filter(
(item) => selectedKeys.value.has(cloudItemKey(item)) && !isInstalled(item),
),
);
const pruneSelectedKeys = (): void => {
selectedKeys.value = new Set(
[...selectedKeys.value].filter((key) => {
const item = props.items.find(
(candidate) => cloudItemKey(candidate) === key,
);
return item ? !isInstalled(item) : !props.installedKeys.has(key);
}),
);
};
const toggleSelection = (item: SyncedAppListItem): void => {
if (isInstalled(item)) return;
const key = cloudItemKey(item);
const next = new Set(selectedKeys.value);
if (next.has(key)) next.delete(key);
else next.add(key);
selectedKeys.value = next;
};
watch(
() => [props.show, props.items] as const,
() => {
selectedKeys.value = new Set();
},
{ deep: true },
);
watch(
() => [props.installedKeys, props.installedPackageKeys] as const,
() => {
pruneSelectedKeys();
},
{ deep: true },
);
</script>
+214 -63
View File
@@ -1,21 +1,44 @@
<template>
<div class="flex h-full flex-col gap-6">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<img
:src="amberLogo"
alt="Amber PM"
class="h-11 w-11 rounded-2xl bg-white/70 p-2 shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-800"
<div class="flex items-start justify-between gap-3">
<div ref="accountMenuRoot" class="relative min-w-0 flex-1">
<button
type="button"
class="flex w-full min-w-0 items-center gap-3 rounded-2xl p-1 text-left transition hover:bg-slate-100 dark:hover:bg-slate-800"
:aria-label="accountLabel"
@click="handleAccountClick"
>
<img
v-if="!currentUser || !currentUser.avatarUrl"
:src="amberLogo"
alt="Amber PM"
class="h-11 w-11 rounded-2xl bg-white/70 p-2 shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-800"
/>
<img
v-else
:src="currentUser.avatarUrl"
:alt="accountLabel"
class="h-11 w-11 rounded-2xl object-cover shadow-sm ring-1 ring-slate-900/5"
/>
<div data-testid="account-text" class="flex min-w-0 flex-col">
<span
class="truncate text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
>{{ currentUser ? currentUser.forumLevel : "Spark Store" }}</span
>
<span
class="truncate text-lg font-semibold text-slate-900 dark:text-white"
>{{ accountLabel }}</span
>
</div>
</button>
<AccountQuickMenu
v-if="currentUser && showAccountMenu"
@open-user-management="emitAccountAction('open-user-management')"
@open-favorites="emitAccountAction('open-favorites')"
@open-forum="emitAccountAction('open-forum')"
@edit-profile="emitAccountAction('edit-profile')"
@logout="emitAccountAction('logout')"
/>
<div class="flex flex-col">
<span
class="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400"
>Spark Store</span
>
<span class="text-lg font-semibold text-slate-900 dark:text-white"
>星火应用商店</span
>
</div>
</div>
<div class="flex items-center gap-1">
<ThemeToggle :theme-mode="themeMode" @toggle="toggleTheme" />
@@ -30,59 +53,53 @@
</div>
</div>
<StoreModeSwitcher />
<div class="flex-1 space-y-2 overflow-y-auto scrollbar-muted px-2 py-1">
<div class="flex-1 space-y-1 overflow-y-auto scrollbar-muted px-1 py-1">
<button
type="button"
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
:class="
activeCategory === 'home'
? 'border-brand/40 bg-brand/10 text-brand dark:bg-brand/15'
: ''
"
@click="selectCategory('home')"
class="sidebar-tab"
:class="{ 'sidebar-tab-active': activeTab === 'home' }"
@click="selectTab('home')"
>
<span>主页</span>
<span class="sidebar-tab-icon"><i class="fas fa-star"></i></span>
<span class="sidebar-tab-label">首页推荐</span>
</button>
<button
type="button"
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
:class="
activeCategory === 'all'
? 'border-brand/40 bg-brand/10 text-brand dark:bg-brand/15'
: ''
"
@click="selectCategory('all')"
class="sidebar-tab"
:class="{ 'sidebar-tab-active': activeTab === 'all' }"
@click="selectTab('all')"
>
<span>全部应用</span>
<span class="sidebar-tab-icon"><i class="fas fa-th-large"></i></span>
<span class="sidebar-tab-label">全部应用</span>
<span
class="ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"
>{{ categoryCounts.all || 0 }}</span
>
</button>
<div
v-if="sidebarEntries.length > 0"
class="my-3 border-t border-slate-100 dark:border-slate-800"
></div>
<button
v-for="(category, key) in categories"
:key="key"
v-for="entry in sidebarEntries"
:key="entry.id"
type="button"
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
:class="
activeCategory === key
? 'border-brand/40 bg-brand/10 text-brand dark:bg-brand/15'
: ''
"
@click="selectCategory(key)"
class="sidebar-tab"
:class="{ 'sidebar-tab-active': activeTab === entry.id }"
@click="selectTab(entry.id)"
>
<span class="flex flex-col">
<span>
<div class="text-left">{{ category.zh }}</div>
</span>
<span class="sidebar-tab-icon">
<i v-if="entry.icon" :class="entry.icon"></i>
<i v-else class="fas fa-folder"></i>
</span>
<span class="sidebar-tab-label">{{ entry.name }}</span>
<span
v-if="entryCounts[entry.id]"
class="ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-500 dark:bg-slate-800/70 dark:text-slate-300"
>{{ categoryCounts[key] || 0 }}</span
>{{ entryCounts[entry.id] }}</span
>
</button>
</div>
@@ -91,49 +108,116 @@
<button
v-if="canManageApps"
type="button"
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
@click="$emit('list')"
class="sidebar-tab"
@click="emitSidebarAction('list')"
>
<i class="fas fa-download"></i>
<span>应用管理</span>
<span class="sidebar-tab-icon"><i class="fas fa-download"></i></span>
<span class="sidebar-tab-label">应用管理</span>
</button>
<button
v-if="canOpenUpdateCenter"
type="button"
class="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-sm font-medium text-slate-600 transition hover:border-brand/30 hover:bg-brand/5 hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 dark:text-slate-300 dark:hover:bg-slate-800"
@click="$emit('update')"
class="sidebar-tab"
@click="emitSidebarAction('update')"
>
<i class="fas fa-sync-alt"></i>
<span>软件更新</span>
<span class="sidebar-tab-icon"><i class="fas fa-sync-alt"></i></span>
<span class="sidebar-tab-label">软件更新</span>
</button>
<button
type="button"
class="sidebar-tab"
@click="emitSidebarAction('submit')"
>
<span class="sidebar-tab-icon"><i class="fas fa-upload"></i></span>
<span class="sidebar-tab-label">投稿应用</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import AccountQuickMenu from "./AccountQuickMenu.vue";
import ThemeToggle from "./ThemeToggle.vue";
import amberLogo from "../assets/imgs/spark-store.svg";
import type { SidebarEntry, SparkUser } from "../global/typedefinition";
const props = defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
categories: Record<string, any>;
activeCategory: string;
activeTab: string;
categoryCounts: Record<string, number>;
themeMode: "light" | "dark" | "auto";
sparkAvailable: boolean;
apmAvailable: boolean;
storeFilter: "spark" | "apm" | "both";
sidebarEntries: SidebarEntry[];
entryCounts: Record<string, number>;
currentUser: SparkUser | null;
}>();
const emit = defineEmits<{
(e: "toggle-theme"): void;
(e: "select-category", category: string): void;
(e: "select-tab", tab: string): void;
(e: "close"): void;
(e: "list"): void;
(e: "update"): void;
(e: "submit"): void;
(e: "request-login"): void;
(e: "open-user-management"): void;
(e: "open-favorites"): void;
(e: "open-forum"): void;
(e: "edit-profile"): void;
(e: "logout"): void;
}>();
const showAccountMenu = ref(false);
const accountMenuRoot = ref<HTMLElement | null>(null);
const accountLabel = computed(() => {
return props.currentUser
? props.currentUser.displayName || props.currentUser.username
: "登录 / 注册";
});
const handleAccountClick = () => {
if (!props.currentUser) {
emit("request-login");
return;
}
showAccountMenu.value = !showAccountMenu.value;
};
const handleDocumentPointerDown = (event: MouseEvent) => {
if (!showAccountMenu.value) return;
const target = event.target;
if (target instanceof Node && accountMenuRoot.value?.contains(target)) return;
showAccountMenu.value = false;
};
onMounted(() => {
document.addEventListener("mousedown", handleDocumentPointerDown);
});
onUnmounted(() => {
document.removeEventListener("mousedown", handleDocumentPointerDown);
});
const emitAccountAction = (
action:
| "open-user-management"
| "open-favorites"
| "open-forum"
| "edit-profile"
| "logout",
) => {
showAccountMenu.value = false;
if (action === "open-user-management") emit("open-user-management");
else if (action === "open-favorites") emit("open-favorites");
else if (action === "open-forum") emit("open-forum");
else if (action === "edit-profile") emit("edit-profile");
else emit("logout");
};
const toggleTheme = () => {
emit("toggle-theme");
};
@@ -147,7 +231,74 @@ const canManageApps = computed(() => {
const canOpenUpdateCenter = canManageApps;
const selectCategory = (category: string) => {
emit("select-category", category);
const selectTab = (tab: string) => {
showAccountMenu.value = false;
emit("select-tab", tab);
};
const emitSidebarAction = (action: "list" | "update" | "submit") => {
showAccountMenu.value = false;
if (action === "list") emit("list");
else if (action === "update") emit("update");
else emit("submit");
};
</script>
<style scoped>
.sidebar-tab {
display: flex;
width: 100%;
align-items: center;
gap: 0.75rem;
border: 1px solid transparent;
border-radius: 0.75rem;
padding: 0.625rem 0.875rem;
text-align: left;
font-size: 0.875rem;
font-weight: 500;
color: #64748b;
transition: all 0.15s ease;
background: transparent;
cursor: pointer;
}
.sidebar-tab:hover {
background: rgba(0, 113, 227, 0.06);
color: #0071e3;
}
.dark .sidebar-tab:hover {
background: rgba(64, 156, 255, 0.1);
color: #409cff;
}
.sidebar-tab-active {
background: rgba(0, 113, 227, 0.1);
color: #0066cc;
border-color: rgba(0, 113, 227, 0.2);
}
.dark .sidebar-tab-active {
background: rgba(64, 156, 255, 0.15);
color: #409cff;
border-color: rgba(64, 156, 255, 0.25);
}
.sidebar-tab-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
font-size: 0.875rem;
flex-shrink: 0;
}
.sidebar-tab-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+132
View File
@@ -0,0 +1,132 @@
<template>
<div class="category-bar-wrapper">
<div class="category-bar">
<button
type="button"
class="category-pill"
:class="{ 'category-pill-active': selectedCategory === 'all' }"
@click="selectCategory('all')"
>
<span>全部</span>
<span v-if="totalCount > 0" class="category-pill-count">{{
totalCount
}}</span>
</button>
<button
v-for="(category, key) in categories"
:key="key"
type="button"
class="category-pill"
:class="{ 'category-pill-active': selectedCategory === key }"
@click="selectCategory(key)"
>
<span>{{ category.zh }}</span>
<span v-if="categoryCounts[key]" class="category-pill-count">{{
categoryCounts[key]
}}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
categories: Record<string, any>;
selectedCategory: string;
categoryCounts: Record<string, number>;
}>();
const emit = defineEmits<{
(e: "select-category", category: string): void;
}>();
const totalCount = computed(() => {
let total = 0;
Object.values(props.categoryCounts).forEach((v) => {
if (typeof v === "number") total += v;
});
return total;
});
const selectCategory = (category: string) => {
emit("select-category", category);
};
</script>
<style scoped>
.category-bar-wrapper {
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 0 1rem;
}
.dark .category-bar-wrapper {
border-color: rgba(30, 41, 59, 0.7);
}
.category-bar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem 0;
}
.category-pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
white-space: nowrap;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
color: #64748b;
background: #f1f5f9;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
}
.category-pill:hover {
background: #e2e8f0;
color: #334155;
}
.dark .category-pill {
background: #1e293b;
color: #94a3b8;
}
.dark .category-pill:hover {
background: #334155;
color: #cbd5e1;
}
.category-pill-active {
background: #2b7fff;
color: #fff;
}
.category-pill-active:hover {
background: #2b7fff;
color: #fff;
}
.dark .category-pill-active {
background: #2b7fff;
color: #fff;
}
.dark .category-pill-active:hover {
background: #2b7fff;
color: #fff;
}
.category-pill-count {
font-size: 0.6875rem;
font-weight: 600;
opacity: 0.75;
}
</style>
+1 -1
View File
@@ -30,7 +30,7 @@
</button>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-400"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/70 text-slate-500 transition hover:text-slate-900 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
@click.stop="toggleExpand"
>
<i
+209
View File
@@ -0,0 +1,209 @@
<template>
<section
class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
我的收藏
</h1>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
管理收藏夹中的应用已下架或不可用项目会保留显示
</p>
</div>
<button
type="button"
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
@click="emit('create-folder')"
>
新建收藏夹
</button>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<button
v-for="folder in folders"
:key="folder.id"
type="button"
class="rounded-full px-4 py-2 text-sm font-medium transition"
:class="
folder.id === activeFolderId
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'
"
@click="emit('select-folder', folder.id)"
>
{{ folder.name }} ({{ folder.itemCount }})
</button>
</div>
<div v-if="loading" class="mt-6 text-sm text-slate-500">加载中...</div>
<div v-else-if="error" class="mt-6 text-sm text-rose-500">{{ error }}</div>
<div v-else class="mt-6 space-y-3">
<div
v-if="items.length === 0"
class="rounded-2xl border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400"
>
当前收藏夹暂无应用
</div>
<div
v-for="resolved in items"
:key="resolved.item.id"
class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 transition hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800/60"
>
<input
v-model="selectedIds"
type="checkbox"
class="h-4 w-4 rounded border-slate-300"
:value="resolved.item.id"
:aria-label="`选择 ${resolved.item.name || resolved.item.pkgname}`"
/>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 text-left"
:aria-label="`打开 ${resolved.item.name || resolved.item.pkgname} 详情`"
:disabled="!resolved.selectedApp"
@click="openFavoriteDetail(resolved)"
>
<img
v-if="resolved.item.iconUrl"
:src="resolved.item.iconUrl"
alt=""
class="h-10 w-10 rounded-xl object-cover"
/>
<span
v-else
class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-sm font-semibold text-slate-500 dark:bg-slate-800"
>
{{ (resolved.item.name || resolved.item.pkgname).slice(0, 1) }}
</span>
<span class="min-w-0 flex-1">
<span
class="block truncate font-medium text-slate-900 dark:text-white"
>
{{ resolved.item.name || resolved.item.pkgname }}
</span>
<span
class="block truncate text-xs text-slate-500 dark:text-slate-400"
>
{{ resolved.item.pkgname }} · {{ resolved.item.category }}
</span>
</span>
</button>
<span
class="rounded-full px-3 py-1 text-xs font-medium"
:class="statusClass(resolved.status)"
>
{{ resolved.reason }}
</span>
</div>
</div>
<div class="mt-6 flex flex-wrap gap-3">
<button
type="button"
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 disabled:opacity-40 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
@click="selectInstallable"
>
选择可安装
</button>
<span class="self-center text-sm text-slate-500 dark:text-slate-400">
{{ installableSelectionMessage }}
</span>
<button
type="button"
class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 disabled:opacity-40 dark:bg-slate-100 dark:text-slate-900"
:disabled="selectedInstallableItems.length === 0"
@click="emit('install-selected', selectedInstallableItems)"
>
加入安装队列
</button>
<button
type="button"
class="rounded-xl border border-rose-300 px-4 py-2 text-sm font-medium text-rose-600 transition hover:bg-rose-50 disabled:opacity-40 dark:border-rose-900/70 dark:hover:bg-rose-950/30"
:disabled="selectedIds.length === 0"
@click="emit('remove-selected', [...selectedIds])"
>
移除选中
</button>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type {
FavoriteAvailabilityStatus,
App,
FavoriteFolder,
ResolvedFavoriteItem,
} from "@/global/typedefinition";
const props = defineProps<{
folders: FavoriteFolder[];
activeFolderId: number | null;
items: ResolvedFavoriteItem[];
loading: boolean;
error: string;
}>();
const emit = defineEmits<{
"select-folder": [folderId: number];
"create-folder": [];
"remove-selected": [itemIds: number[]];
"install-selected": [items: ResolvedFavoriteItem[]];
"open-detail": [app: App];
}>();
const selectedIds = ref<number[]>([]);
const selectedInstallableItems = computed(() =>
props.items.filter(
(item) =>
selectedIds.value.includes(item.item.id) && item.status === "installable",
),
);
const installableItemCount = computed(
() => props.items.filter((item) => item.status === "installable").length,
);
const installableSelectionMessage = computed(() => {
if (selectedInstallableItems.value.length > 0) {
return `已选择 ${selectedInstallableItems.value.length} 个可安装应用`;
}
if (installableItemCount.value === 0) return "当前收藏夹没有可安装应用";
return `${installableItemCount.value} 个应用可安装`;
});
watch(
() => props.items,
() => {
const visibleIds = new Set(props.items.map((item) => item.item.id));
selectedIds.value = selectedIds.value.filter((id) => visibleIds.has(id));
},
);
const selectInstallable = () => {
selectedIds.value = props.items
.filter((item) => item.status === "installable")
.map((item) => item.item.id);
};
const openFavoriteDetail = (resolved: ResolvedFavoriteItem) => {
if (!resolved.selectedApp) return;
emit("open-detail", resolved.selectedApp);
};
const statusClass = (status: FavoriteAvailabilityStatus): string => {
if (status === "installable") {
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
}
if (status === "installed") {
return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300";
}
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
};
</script>
+103
View File
@@ -0,0 +1,103 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-[90] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/40" @click="emit('close')"></div>
<section
class="relative z-10 w-full max-w-sm rounded-3xl border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-800 dark:bg-slate-900"
role="dialog"
aria-modal="true"
aria-label="选择收藏夹"
>
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
管理收藏夹
</h2>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
勾选要保存当前应用的收藏夹取消勾选可移出收藏
</p>
<div class="mt-5 space-y-2">
<label
v-if="!hasDefaultFolder"
class="flex w-full items-center gap-3 rounded-xl border border-slate-200 px-4 py-3 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
<input
v-model="draftSelectedIds"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
aria-label="收藏到 默认收藏夹"
value="default"
/>
<span>默认收藏夹</span>
</label>
<label
v-for="folder in folders"
:key="folder.id"
class="flex w-full items-center gap-3 rounded-xl border border-slate-200 px-4 py-3 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
<input
v-model="draftSelectedIds"
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
:aria-label="`收藏到 ${folder.name}`"
:value="folder.id"
/>
<span>{{ folder.name }}</span>
</label>
</div>
<button
type="button"
class="mt-4 w-full rounded-xl border border-dashed border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-blue-400 hover:text-blue-600 dark:border-slate-700 dark:text-slate-300 dark:hover:border-blue-500 dark:hover:text-blue-300"
@click="emit('create-folder', [...draftSelectedIds])"
>
新建收藏夹
</button>
<button
type="button"
class="mt-5 w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900"
@click="emit('save-selection', [...draftSelectedIds])"
>
保存收藏夹
</button>
<button
type="button"
class="mt-2 w-full rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
@click="emit('close')"
>
取消
</button>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { FavoriteFolder } from "@/global/typedefinition";
const props = defineProps<{
show: boolean;
folders: FavoriteFolder[];
selectedFolderIds?: Array<number | "default">;
}>();
const hasDefaultFolder = computed(() =>
props.folders.some((folder) => folder.name.trim() === "默认收藏夹"),
);
const emit = defineEmits<{
close: [];
"save-selection": [folderIds: Array<number | "default">];
"create-folder": [folderIds: Array<number | "default">];
}>();
const draftSelectedIds = ref<Array<number | "default">>([]);
watch(
() => [props.show, props.selectedFolderIds] as const,
() => {
if (!props.show) return;
draftSelectedIds.value = [...(props.selectedFolderIds ?? [])];
},
{ immediate: true },
);
</script>
+3 -4
View File
@@ -72,7 +72,6 @@
class="group block overflow-hidden rounded-xl transition-transform duration-300 hover:scale-[1.02]"
:title="link.more as string"
>
<!-- 图片区域 - 850:400 比例 -->
<div
class="relative w-full aspect-[850/400] overflow-hidden rounded-xl bg-slate-100 dark:bg-slate-800"
>
@@ -88,7 +87,6 @@
imageLoaded[link.url + link.name],
}"
/>
<!-- 图片加载占位符 -->
<div
v-if="!imageLoaded[link.url + link.name]"
class="absolute inset-0 flex items-center justify-center"
@@ -98,7 +96,6 @@
></div>
</div>
</div>
<!-- 文字信息区域 -->
<div class="mt-3 px-1">
<div
class="text-base font-semibold text-slate-900 dark:text-white group-hover:text-brand dark:group-hover:text-brand transition-colors"
@@ -118,7 +115,9 @@
<div v-if="lists.length > 0" class="space-y-6 mt-6">
<section v-for="section in lists" :key="section.title">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-900">
<h3
class="text-lg font-semibold text-slate-900 dark:text-slate-200"
>
{{ section.title }}
</h3>
</div>
+48 -1
View File
@@ -28,6 +28,23 @@
</p>
</div>
<div class="flex items-center gap-3">
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-brand/30 px-4 py-2 text-sm font-semibold text-brand transition hover:bg-brand/10 disabled:opacity-40"
:disabled="syncing"
@click="handleSyncClick"
>
<i class="fas fa-cloud-arrow-up"></i>
{{ syncing ? "同步中" : "同步到账号" }}
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-2xl border border-slate-200/70 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
@click="handleRestoreClick"
>
<i class="fas fa-cloud-arrow-down"></i>
从账号恢复
</button>
<div
v-if="showOriginSwitcher"
class="flex items-center rounded-2xl border border-slate-200/70 p-1 dark:border-slate-800/70"
@@ -83,6 +100,12 @@
<div
class="flex-1 overflow-y-auto overscroll-contain scrollbar-nowidth scrollbar-thumb-slate-200 dark:scrollbar-thumb-slate-700 p-6 space-y-4"
>
<div
v-if="syncMessage"
class="rounded-2xl border border-brand/20 bg-brand/5 px-4 py-3 text-sm font-medium text-brand dark:bg-brand/10"
>
{{ syncMessage }}
</div>
<div
v-if="loading"
class="rounded-2xl border border-dashed border-slate-200/80 px-4 py-10 text-center text-slate-500 dark:border-slate-800/80 dark:text-slate-400"
@@ -220,17 +243,41 @@ const props = defineProps<{
storeFilter: "spark" | "apm" | "both";
sparkAvailable: boolean;
apmAvailable: boolean;
loggedIn: boolean;
syncing: boolean;
syncMessage: string;
}>();
defineEmits<{
const emit = defineEmits<{
(e: "close"): void;
(e: "refresh"): void;
(e: "uninstall", app: App): void;
(e: "switch-origin", origin: "apm" | "spark"): void;
(e: "open-app", app: App): void;
(e: "open-detail", app: App): void;
(e: "sync-to-account"): void;
(e: "restore-from-account"): void;
(e: "request-login"): void;
}>();
const handleSyncClick = () => {
if (props.loggedIn) {
emit("sync-to-account");
return;
}
emit("request-login");
};
const handleRestoreClick = () => {
if (props.loggedIn) {
emit("restore-from-account");
return;
}
emit("request-login");
};
const onOverlayWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
if (target.closest(".overflow-y-auto, .overflow-auto")) return;
+115
View File
@@ -0,0 +1,115 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
@click.self="emit('close')"
>
<form
class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-6 shadow-2xl dark:border-slate-700 dark:bg-slate-900"
@submit.prevent="submitLogin"
>
<div class="mb-6">
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">
登录星火账号
</h2>
</div>
<label class="mb-4 block">
<span
class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200"
>
论坛账号
</span>
<input
v-model="identification"
type="text"
autocomplete="username"
class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-slate-900 outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white"
/>
</label>
<label class="mb-4 block">
<span
class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200"
>
论坛密码
</span>
<input
v-model="password"
type="password"
autocomplete="current-password"
class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-slate-900 outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white"
/>
</label>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
<div class="flex items-center justify-between gap-3">
<button
type="button"
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-500 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
@click="emit('register')"
>
注册账号
</button>
<div class="flex items-center gap-3">
<button
type="button"
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="emit('close')"
>
取消
</button>
<button
type="submit"
class="rounded-xl bg-brand px-5 py-2 text-sm font-semibold text-white transition hover:bg-brand/90 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="loading"
>
{{ loading ? "登录中..." : "登录" }}
</button>
</div>
</div>
</form>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { FlarumLoginPayload } from "@/global/typedefinition";
defineProps<{
show: boolean;
loading: boolean;
error: string;
}>();
const emit = defineEmits<{
close: [];
login: [payload: FlarumLoginPayload];
register: [];
}>();
const identification = ref("");
const password = ref("");
const submitLogin = () => {
emit("login", {
identification: identification.value.trim(),
password: password.value.trim(),
});
};
</script>
+58
View File
@@ -0,0 +1,58 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-[85] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
@click.self="emit('close')"
>
<div
class="w-full max-w-sm rounded-3xl border border-slate-200 bg-white p-6 shadow-2xl dark:border-slate-700 dark:bg-slate-900"
>
<h2 class="text-xl font-bold text-slate-900 dark:text-white">
需要登录
</h2>
<p class="mt-3 text-sm leading-6 text-slate-500 dark:text-slate-400">
{{ message }}
</p>
<div class="mt-6 flex items-center justify-end gap-3">
<button
type="button"
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-500 transition hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
@click="emit('register')"
>
注册账号
</button>
<button
type="button"
class="rounded-xl bg-brand px-5 py-2 text-sm font-semibold text-white transition hover:bg-brand/90"
@click="emit('login')"
>
登录
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
show: boolean;
message: string;
}>();
const emit = defineEmits<{
close: [];
login: [];
register: [];
}>();
</script>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div
v-if="show && profile"
ref="dialogRef"
class="fixed inset-0 z-[90] flex items-center justify-center bg-slate-950/60 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="用户资料"
tabindex="-1"
@click.self="emit('close')"
@keydown.esc="emit('close')"
>
<section
class="w-full max-w-md overflow-hidden rounded-3xl border border-white/20 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900"
>
<div
data-testid="review-user-cover"
class="relative h-32 bg-gradient-to-br from-brand via-sky-500 to-violet-500"
:style="coverStyle"
>
<button
ref="closeButtonRef"
type="button"
class="absolute top-3 right-3 inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/30 text-white transition hover:bg-black/45"
aria-label="关闭"
@click="emit('close')"
>
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="px-6 pb-6">
<div class="-mt-10 flex items-end gap-4">
<img
v-if="profile.avatarUrl"
:src="profile.avatarUrl"
:alt="profile.displayName"
class="h-20 w-20 rounded-3xl border-4 border-white bg-white object-cover shadow-lg dark:border-slate-900 dark:bg-slate-800"
/>
<div
v-else
class="flex h-20 w-20 items-center justify-center rounded-3xl border-4 border-white bg-slate-900 text-3xl font-semibold text-white shadow-lg dark:border-slate-900"
>
{{ initial }}
</div>
<div class="min-w-0 pb-1">
<h2
class="truncate text-2xl font-bold text-slate-900 dark:text-white"
>
{{ profile.displayName }}
</h2>
<p v-if="profile.username" class="text-sm text-slate-500">
@{{ profile.username }}
</p>
</div>
</div>
<div
v-if="profile.forumGroups?.length"
class="mt-5 flex flex-wrap gap-2"
>
<span
v-for="group in profile.forumGroups"
:key="group"
class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{{ group }}
</span>
</div>
<button
v-if="forumUrl"
type="button"
class="mt-6 inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200"
@click="emit('open-forum-profile', forumUrl)"
>
<i class="fas fa-up-right-from-square"></i>
查看论坛资料
</button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { FLARUM_PROFILE_URL } from "@/global/storeConfig";
import type { ReviewUserProfile } from "@/global/typedefinition";
const props = defineProps<{
show: boolean;
profile: ReviewUserProfile | null;
}>();
const emit = defineEmits<{
close: [];
"open-forum-profile": [url: string];
}>();
const dialogRef = ref<HTMLElement | null>(null);
const closeButtonRef = ref<HTMLButtonElement | null>(null);
const initial = computed(() => (props.profile?.displayName || "?").slice(0, 1));
const safeCoverUrl = computed(() => {
const raw = props.profile?.coverUrl;
if (!raw) return "";
try {
const parsed = new URL(raw);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString()
: "";
} catch {
return "";
}
});
const coverStyle = computed(() => ({
backgroundImage: safeCoverUrl.value
? `url(${JSON.stringify(safeCoverUrl.value)})`
: "",
}));
const forumUrl = computed(() =>
props.profile?.username
? `${FLARUM_PROFILE_URL}/${props.profile.username}`
: "",
);
watch(
() => props.show && props.profile !== null,
async (visible) => {
if (!visible) return;
await nextTick();
if (closeButtonRef.value) {
closeButtonRef.value.focus();
} else {
dialogRef.value?.focus();
}
},
{ immediate: true },
);
</script>
+695
View File
@@ -0,0 +1,695 @@
<template>
<section
class="rounded-2xl border border-slate-200/60 bg-slate-50/50 p-5 dark:border-slate-800/60 dark:bg-slate-800/30"
>
<div class="mb-4 flex items-center justify-between gap-3">
<h2 class="flex items-center gap-2 text-base font-semibold">
<i class="fas fa-comments text-slate-400"></i>
应用评价
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ ratingText }}
</p>
</div>
<dl class="mb-4 grid gap-2 text-xs text-slate-500 sm:grid-cols-2">
<div
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
>
<dt>版本</dt>
<dd class="font-medium text-slate-700 dark:text-slate-300">
{{ tags.version }}
</dd>
</div>
<div
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
>
<dt>发行版</dt>
<dd class="font-medium text-slate-700 dark:text-slate-300">
{{ tags.distro }}
</dd>
</div>
<div
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
>
<dt>架构</dt>
<dd class="font-medium text-slate-700 dark:text-slate-300">
{{ tags.packageArch }}
</dd>
</div>
<div
class="flex justify-between gap-3 rounded-xl bg-white px-3 py-2 dark:bg-slate-900/60"
>
<dt>来源</dt>
<dd class="font-medium uppercase text-slate-700 dark:text-slate-300">
{{ tags.origin }}
</dd>
</div>
</dl>
<button
v-if="!loggedIn"
type="button"
class="mb-4 inline-flex items-center rounded-xl bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
@click="emit('request-login', '登录后发表评论')"
>
登录后发表评论
</button>
<p
v-else-if="!canSubmit"
class="mb-4 text-sm text-slate-500 dark:text-slate-400"
>
安装应用后可发表评论
</p>
<form v-else class="mb-4 space-y-3" @submit.prevent="submit">
<div class="block text-sm font-medium text-slate-600 dark:text-slate-300">
评分
<div
role="slider"
aria-label="评分"
:aria-valuemin="1"
:aria-valuemax="5"
:aria-valuenow="rating"
tabindex="0"
class="mt-2 inline-flex touch-none select-none items-center gap-1 text-2xl text-amber-400 outline-none transition focus:ring-2 focus:ring-amber-300"
@pointerdown="startRatingSlide"
@pointermove="moveRatingSlide"
@pointerup="stopRatingSlide"
@pointercancel="stopRatingSlide"
@keydown="handleRatingKeydown"
>
<span
v-for="value in ratingOptions"
:key="value"
class="leading-none"
aria-hidden="true"
>
{{ value <= rating ? "★" : "☆" }}
</span>
</div>
</div>
<label
class="block text-sm font-medium text-slate-600 dark:text-slate-300"
>
评论
<textarea
v-model="content"
rows="3"
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
placeholder="分享你的使用体验"
></textarea>
</label>
<button
type="submit"
class="inline-flex items-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-500 disabled:opacity-50"
:disabled="submitting"
>
发表评论
</button>
</form>
<p v-if="actionMessage" class="mb-3 text-sm text-blue-500">
{{ actionMessage }}
</p>
<div
v-if="reviews.length"
class="mb-3 grid gap-2 text-sm text-slate-600 sm:grid-cols-2 dark:text-slate-300"
>
<label class="block">
按架构筛选
<select
v-model="selectedPackageArch"
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="">全部架构</option>
<option v-for="arch in packageArchOptions" :key="arch" :value="arch">
{{ arch }}
</option>
</select>
</label>
<label class="block">
按发行版筛选
<select
v-model="selectedDistro"
class="mt-1 block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
>
<option value="">全部发行版</option>
<option v-for="distro in distroOptions" :key="distro" :value="distro">
{{ distro }}
</option>
</select>
</label>
</div>
<p v-if="loading" class="text-sm text-slate-400">正在加载评价...</p>
<p v-else-if="error" class="text-sm text-rose-500">{{ error }}</p>
<div v-else-if="filteredReviews.length" class="space-y-3">
<article
v-for="review in filteredReviews"
:key="review.id"
class="rounded-xl bg-white p-3 text-sm dark:bg-slate-900/60"
>
<div class="flex gap-3">
<button
type="button"
class="h-9 w-9 flex-shrink-0 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-400"
:aria-label="`查看${reviewerName(review)}的资料`"
@click="showReviewer(review)"
>
<img
v-if="review.userAvatarUrl"
:src="review.userAvatarUrl"
:alt="`${reviewerName(review)} 的头像`"
class="h-9 w-9 rounded-full bg-slate-100 object-cover dark:bg-slate-800"
loading="lazy"
referrerpolicy="no-referrer"
@error="hideAvatar"
/>
<span
v-else
class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
aria-hidden="true"
>
{{ reviewerName(review).slice(0, 1) }}
</span>
</button>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-3">
<button
type="button"
class="min-w-0 truncate text-left font-semibold text-slate-700 hover:text-blue-600 dark:text-slate-200 dark:hover:text-blue-300"
@click="showReviewer(review)"
>
{{ reviewerName(review) }}
</button>
<span class="flex-shrink-0 text-xs text-slate-400"
>{{ review.rating }} </span
>
</div>
<p
class="mt-2 whitespace-pre-wrap text-slate-600 dark:text-slate-300"
>
{{ review.content || "暂无评论内容" }}
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-slate-500 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="toggleReviewLike(review)"
>
点赞{{ review.likeCount ? ` ${review.likeCount}` : "" }}
</button>
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-slate-500 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
@click="startReply(review.id)"
>
回复
</button>
<button
v-if="canDeleteReview(review)"
type="button"
class="rounded-full bg-rose-50 px-3 py-1 text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
@click="removeReview(review)"
>
删除
</button>
</div>
<form
v-if="
replyTarget?.reviewId === review.id &&
replyTarget.parentId === undefined
"
class="mt-3 space-y-2"
@submit.prevent="submitReply"
>
<textarea
v-model="replyContent"
rows="2"
class="block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
placeholder="写下你的回复"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-full bg-blue-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-50"
:disabled="replySubmitting"
>
发送回复
</button>
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-300"
@click="cancelReply"
>
取消
</button>
</div>
</form>
<div
v-if="review.replies?.length"
class="mt-3 space-y-2 border-l border-slate-200 pl-3 dark:border-slate-700"
>
<div
v-for="reply in flattenReplies(review.replies)"
:key="reply.id"
class="rounded-xl bg-slate-50 px-3 py-2 dark:bg-slate-800/60"
:style="{ marginLeft: `${reply.depth * 12}px` }"
>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-slate-700 dark:text-slate-200">
{{ reply.userDisplayName || "星火用户" }}
</span>
<span v-if="reply.isDeleted" class="text-xs text-slate-400"
>已删除</span
>
</div>
<p
class="mt-1 whitespace-pre-wrap text-slate-600 dark:text-slate-300"
>
{{ reply.content || "该回复已删除" }}
</p>
<div class="mt-2 flex flex-wrap gap-2 text-xs">
<button
type="button"
class="rounded-full bg-white px-3 py-1 text-slate-500 transition hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700"
@click="toggleReplyLike(review.id, reply)"
>
点赞{{ reply.likeCount ? ` ${reply.likeCount}` : "" }}
</button>
<button
type="button"
class="rounded-full bg-white px-3 py-1 text-slate-500 transition hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-700"
@click="startReply(review.id, reply.id)"
>
回复
</button>
<button
v-if="reply.canDelete"
type="button"
class="rounded-full bg-rose-50 px-3 py-1 text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
@click="removeReply(review.id, reply)"
>
删除
</button>
</div>
<form
v-if="
replyTarget?.reviewId === review.id &&
replyTarget.parentId === reply.id
"
class="mt-2 space-y-2"
@submit.prevent="submitReply"
>
<textarea
v-model="replyContent"
rows="2"
class="block w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-900"
placeholder="写下你的回复"
></textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-full bg-blue-600 px-3 py-1 text-xs font-medium text-white disabled:opacity-50"
:disabled="replySubmitting"
>
发送回复
</button>
<button
type="button"
class="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-300"
@click="cancelReply"
>
取消
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</article>
</div>
<p v-else class="text-sm text-slate-400">
{{ reviews.length ? "没有符合筛选条件的评价" : "暂无评价" }}
</p>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import {
createReviewReply,
deleteReview,
deleteReviewReply,
fetchRatingSummary,
fetchReviews,
likeReview,
likeReviewReply,
submitReview,
} from "@/modules/backendApi";
import type {
AppReview,
AppReviewReply,
RatingSummary,
ReviewTags,
} from "@/global/typedefinition";
const props = withDefaults(
defineProps<{
appKey: string;
tags: ReviewTags;
loggedIn: boolean;
canSubmit?: boolean;
currentUserId?: number;
currentUserIsAdmin?: boolean;
}>(),
{ canSubmit: true, currentUserId: undefined, currentUserIsAdmin: false },
);
const emit = defineEmits<{
"request-login": [message: string];
"show-user": [review: AppReview];
}>();
const ratingOptions = [1, 2, 3, 4, 5];
const rating = ref(5);
const ratingSliding = ref(false);
const content = ref("");
const reviews = ref<AppReview[]>([]);
const summary = ref<RatingSummary | null>(null);
const loading = ref(false);
const submitting = ref(false);
const error = ref("");
const actionMessage = ref("");
const loadGeneration = ref(0);
const selectedPackageArch = ref("");
const selectedDistro = ref("");
const replyContent = ref("");
const replySubmitting = ref(false);
const replyTarget = ref<{ reviewId: number; parentId?: number } | null>(null);
interface FlatReply extends AppReviewReply {
depth: number;
}
const ratingText = computed(() => {
if (!summary.value || summary.value.reviewCount === 0) return "暂无评分";
return `${summary.value.averageRating.toFixed(1)} / 5 (${summary.value.reviewCount})`;
});
const canSubmit = computed(() => props.canSubmit);
const packageArchOptions = computed(() =>
[
...new Set(
reviews.value.map((review) => review.packageArch).filter(Boolean),
),
].sort(),
);
const distroOptions = computed(() =>
[
...new Set(reviews.value.map((review) => review.distro).filter(Boolean)),
].sort(),
);
const filteredReviews = computed(() =>
reviews.value.filter(
(review) =>
(!selectedPackageArch.value ||
review.packageArch === selectedPackageArch.value) &&
(!selectedDistro.value || review.distro === selectedDistro.value),
),
);
const toReviewErrorMessage = (
caught: unknown,
fallback = "发表评论失败",
): string => {
const message = caught instanceof Error ? caught.message : "";
if (message === "Network Error") {
return "无法连接星火账号服务,请稍后重试。";
}
if (message.includes("401")) {
return "登录状态已失效,请重新登录星火账号。";
}
return message || fallback;
};
const clampRating = (value: number): number => Math.min(5, Math.max(1, value));
const updateRatingFromPointer = (event: PointerEvent): void => {
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
const rect = target.getBoundingClientRect();
if (rect.width <= 0) return;
const eventWithClientX = event as PointerEvent & { clientX: number };
const ratio = (eventWithClientX.clientX - rect.left) / rect.width;
rating.value = clampRating(Math.ceil(ratio * 5));
};
const startRatingSlide = (event: PointerEvent): void => {
ratingSliding.value = true;
const target = event.currentTarget;
if (target instanceof HTMLElement && "setPointerCapture" in target) {
target.setPointerCapture(event.pointerId);
}
updateRatingFromPointer(event);
};
const moveRatingSlide = (event: PointerEvent): void => {
updateRatingFromPointer(event);
};
const stopRatingSlide = (event: PointerEvent): void => {
ratingSliding.value = false;
const target = event.currentTarget;
if (
target instanceof HTMLElement &&
"hasPointerCapture" in target &&
target.hasPointerCapture(event.pointerId)
) {
target.releasePointerCapture(event.pointerId);
}
};
const handleRatingKeydown = (event: KeyboardEvent): void => {
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
rating.value = clampRating(rating.value - 1);
}
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
rating.value = clampRating(rating.value + 1);
}
};
const hideAvatar = (event: Event) => {
(event.target as HTMLElement).style.display = "none";
};
const reviewerName = (review: AppReview): string =>
review.userDisplayName || "星火用户";
const showReviewer = (review: AppReview) => {
actionMessage.value = `正在查看 ${reviewerName(review)} 的资料`;
emit("show-user", review);
};
const canDeleteReview = (review: AppReview): boolean =>
review.canDelete === true;
const flattenReplies = (items: AppReviewReply[] = [], depth = 0): FlatReply[] =>
items.flatMap((reply) => [
{ ...reply, depth },
...flattenReplies(reply.replies, depth + 1),
]);
const startReply = (reviewId: number, parentId?: number) => {
replyTarget.value = { reviewId, parentId };
replyContent.value = "";
actionMessage.value = "";
};
const cancelReply = () => {
replyTarget.value = null;
replyContent.value = "";
};
const toReviewActionErrorMessage = (
caught: unknown,
fallback = "操作失败,请稍后重试。",
): string => {
return toReviewErrorMessage(caught, fallback);
};
const resetFilters = () => {
selectedPackageArch.value = "";
selectedDistro.value = "";
};
const resetStaleFilters = () => {
if (
selectedPackageArch.value &&
!packageArchOptions.value.includes(selectedPackageArch.value)
) {
selectedPackageArch.value = "";
}
if (
selectedDistro.value &&
!distroOptions.value.includes(selectedDistro.value)
) {
selectedDistro.value = "";
}
};
const clearReviewState = () => {
loadGeneration.value += 1;
reviews.value = [];
summary.value = null;
loading.value = false;
error.value = "";
actionMessage.value = "";
};
const loadReviews = async () => {
if (!props.loggedIn || !props.appKey) {
clearReviewState();
return;
}
const generation = loadGeneration.value + 1;
loadGeneration.value = generation;
const appKey = props.appKey;
loading.value = true;
error.value = "";
try {
const [nextSummary, nextReviews] = await Promise.all([
fetchRatingSummary(appKey),
fetchReviews(appKey),
]);
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
summary.value = nextSummary;
reviews.value = nextReviews;
resetStaleFilters();
} catch (caught: unknown) {
if (generation !== loadGeneration.value || appKey !== props.appKey) return;
error.value = toReviewErrorMessage(caught, "加载评价失败");
} finally {
if (generation === loadGeneration.value && appKey === props.appKey) {
loading.value = false;
}
}
};
const submit = async () => {
if (!canSubmit.value) return;
const appKey = props.appKey;
const tags = props.tags;
submitting.value = true;
error.value = "";
try {
await submitReview(appKey, {
rating: rating.value,
content: content.value.trim(),
tags,
});
if (appKey !== props.appKey) return;
content.value = "";
await loadReviews();
} catch (caught: unknown) {
if (appKey !== props.appKey) return;
error.value = toReviewErrorMessage(caught);
} finally {
if (appKey === props.appKey) {
submitting.value = false;
}
}
};
const toggleReviewLike = async (review: AppReview) => {
try {
await likeReview(props.appKey, review.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"请登录星火账号后再点赞。",
);
}
};
const toggleReplyLike = async (reviewId: number, reply: AppReviewReply) => {
try {
await likeReviewReply(props.appKey, reviewId, reply.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"请登录星火账号后再点赞。",
);
}
};
const submitReply = async () => {
const target = replyTarget.value;
const trimmed = replyContent.value.trim();
if (!target || trimmed === "") return;
replySubmitting.value = true;
try {
await createReviewReply(props.appKey, target.reviewId, {
content: trimmed,
...(target.parentId === undefined ? {} : { parentId: target.parentId }),
});
cancelReply();
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"请登录星火账号后再回复。",
);
} finally {
replySubmitting.value = false;
}
};
const removeReview = async (review: AppReview) => {
if (!canDeleteReview(review)) return;
try {
await deleteReview(props.appKey, review.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"没有权限删除该内容。请刷新后重试。",
);
}
};
const removeReply = async (reviewId: number, reply: AppReviewReply) => {
if (!reply.canDelete) return;
try {
await deleteReviewReply(props.appKey, reviewId, reply.id);
await loadReviews();
} catch (caught: unknown) {
actionMessage.value = toReviewActionErrorMessage(
caught,
"没有权限删除该内容。请刷新后重试。",
);
}
};
onMounted(loadReviews);
watch(
() => props.appKey,
() => {
resetFilters();
void loadReviews();
},
);
watch(() => props.loggedIn, loadReviews);
</script>
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
<template>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/60 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="用户管理"
>
<button
type="button"
class="absolute inset-0 cursor-default"
aria-label="关闭用户管理"
@click="emit('close')"
></button>
<div
class="relative z-10 flex h-[88vh] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-950"
>
<header
class="flex shrink-0 items-center justify-between gap-4 border-b border-slate-200 px-5 py-4 dark:border-slate-800"
>
<div class="min-w-0">
<p class="text-xs uppercase tracking-[0.3em] text-slate-500">
Account Center
</p>
<h2
class="truncate text-lg font-semibold text-slate-900 dark:text-white"
>
星火账号用户管理
</h2>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
aria-label="刷新"
@click="refreshFrame"
>
<i class="fas fa-rotate-right"></i>
<span class="hidden sm:inline">刷新</span>
</button>
<button
type="button"
class="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
aria-label="在浏览器中打开"
@click="openInBrowser"
>
<i class="fas fa-up-right-from-square"></i>
<span class="hidden sm:inline">浏览器打开</span>
</button>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-500 transition hover:text-slate-900 dark:bg-slate-800 dark:text-slate-300 dark:hover:text-white"
aria-label="关闭"
@click="emit('close')"
>
<i class="fas fa-times"></i>
</button>
</div>
</header>
<div class="relative min-h-0 flex-1 bg-slate-100 dark:bg-slate-900">
<iframe
v-if="!loadFailed"
:key="frameVersion"
class="h-full w-full border-0 bg-white"
title="星火账号用户管理"
:src="accountFrameUrl"
referrerpolicy="no-referrer"
sandbox="allow-forms allow-popups allow-same-origin allow-scripts"
@error="handleFrameError"
></iframe>
<div
v-else
class="flex h-full flex-col items-center justify-center gap-4 p-8 text-center"
>
<div
class="flex h-14 w-14 items-center justify-center rounded-2xl bg-red-100 text-red-600 dark:bg-red-950/50 dark:text-red-300"
>
<i class="fas fa-triangle-exclamation"></i>
</div>
<div>
<p class="text-lg font-semibold text-slate-900 dark:text-white">
账号页面加载失败
</p>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
请检查网络连接后重试
</p>
</div>
<button
type="button"
class="rounded-full bg-sky-600 px-5 py-2 text-sm font-medium text-white transition hover:bg-sky-500"
@click="retryFrame"
>
重试
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { SPARK_ACCOUNT_CENTER_URL } from "@/global/storeConfig";
import { buildAccountFrameUrl } from "@/modules/accountCenterUrl";
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
const props = defineProps<{
show: boolean;
user: SparkUser;
downloadedApps: DownloadedAppRecord[];
syncEnabled: boolean;
loading: boolean;
error: string;
syncing?: boolean;
syncMessage?: string;
}>();
const emit = defineEmits<{
close: [];
"open-forum": [];
"edit-profile": [];
"toggle-sync": [enabled: boolean];
"sync-now": [];
"refresh-downloads": [];
}>();
const loadFailed = ref(false);
const frameVersion = ref(0);
const accountFrameUrl = computed(() =>
buildAccountFrameUrl(SPARK_ACCOUNT_CENTER_URL, props.user.username),
);
const refreshFrame = () => {
loadFailed.value = false;
frameVersion.value += 1;
};
const retryFrame = () => {
refreshFrame();
};
const handleFrameError = () => {
loadFailed.value = true;
};
const openInBrowser = () => {
window.open(accountFrameUrl.value, "_blank", "noopener,noreferrer");
};
</script>
+201
View File
@@ -0,0 +1,201 @@
<template>
<section
class="space-y-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div
v-if="user.coverUrl"
data-testid="profile-cover"
class="h-32 rounded-2xl bg-cover bg-center sm:h-40"
:style="coverStyle"
></div>
<div
class="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-4">
<img
v-if="user.avatarUrl"
:src="user.avatarUrl"
:alt="user.displayName"
class="h-16 w-16 rounded-2xl border border-slate-200 object-cover dark:border-slate-700"
/>
<div
v-else
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-100 text-2xl font-semibold text-slate-500 dark:bg-slate-800 dark:text-slate-300"
>
{{ userInitial }}
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">
用户管理
</h1>
<p
class="mt-1 truncate text-lg font-medium text-slate-800 dark:text-slate-100"
>
{{ user.displayName }}
</p>
<p class="truncate text-sm text-slate-500 dark:text-slate-400">
@{{ user.username }}
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ user.forumLevel }}
</p>
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
@click="emit('open-forum')"
>
论坛首页
</button>
<button
type="button"
class="rounded-full bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-500"
@click="emit('edit-profile')"
>
修改论坛资料
</button>
</div>
</div>
<div
v-if="visibleForumGroups.length > 0"
class="flex flex-wrap gap-2 text-sm text-slate-600 dark:text-slate-300"
>
<span
v-for="group in visibleForumGroups"
:key="group"
class="rounded-full bg-slate-100 px-3 py-1 dark:bg-slate-800"
>
{{ group }}
</span>
</div>
<div
class="flex flex-col gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950/40 sm:flex-row sm:items-center sm:justify-between"
>
<label
class="flex items-center gap-3 text-sm font-medium text-slate-700 dark:text-slate-200"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
aria-label="自动同步已安装应用"
:checked="syncEnabled"
@change="handleSyncToggle"
/>
自动同步已安装应用
</label>
<button
type="button"
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
:disabled="syncing"
@click="emit('sync-now')"
>
{{ syncing ? "同步中..." : "立即同步" }}
</button>
</div>
<p v-if="syncMessage" class="text-sm text-sky-600 dark:text-sky-300">
{{ syncMessage }}
</p>
<section class="space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-slate-900 dark:text-white">
下载历史
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400">
最近通过当前账号记录的应用安装历史
</p>
</div>
<button
type="button"
class="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-sky-300 hover:text-sky-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-200 dark:hover:border-sky-500 dark:hover:text-sky-300"
:disabled="loading"
@click="emit('refresh-downloads')"
>
刷新
</button>
</div>
<p v-if="loading" class="text-sm text-slate-500 dark:text-slate-400">
正在加载下载历史...
</p>
<p v-else-if="error" class="text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
<p
v-else-if="downloadedApps.length === 0"
class="text-sm text-slate-500 dark:text-slate-400"
>
暂无下载记录
</p>
<ul v-else class="divide-y divide-slate-200 dark:divide-slate-800">
<li
v-for="app in downloadedApps"
:key="app.id"
class="flex flex-col gap-1 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p class="font-medium text-slate-900 dark:text-white">
{{ app.name }}
</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ app.pkgname }} · {{ app.category }}
</p>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ app.selectedOrigin.toUpperCase() }} · {{ app.version }} ·
{{ app.packageArch }}
</p>
</li>
</ul>
</section>
</section>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { DownloadedAppRecord, SparkUser } from "@/global/typedefinition";
const props = defineProps<{
user: SparkUser;
downloadedApps: DownloadedAppRecord[];
syncEnabled: boolean;
loading: boolean;
error: string;
syncing?: boolean;
syncMessage?: string;
}>();
const emit = defineEmits<{
"open-forum": [];
"edit-profile": [];
"toggle-sync": [enabled: boolean];
"sync-now": [];
"refresh-downloads": [];
}>();
const userInitial = computed(() =>
(props.user.displayName || props.user.username || "?").slice(0, 1),
);
const coverStyle = computed(() => ({
backgroundImage: props.user.coverUrl ? `url("${props.user.coverUrl}")` : "",
}));
const visibleForumGroups = computed(() =>
props.user.forumGroups.filter((group) => group !== props.user.forumLevel),
);
const handleSyncToggle = (event: Event): void => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) return;
emit("toggle-sync", target.checked);
};
</script>
+65
View File
@@ -0,0 +1,65 @@
<template>
<header
class="window-titlebar sticky top-0 z-20 flex h-10 shrink-0 items-center justify-between border-b border-slate-200/70 bg-white/95 px-3 text-slate-700 backdrop-blur dark:border-slate-800/70 dark:bg-slate-950/95 dark:text-slate-200"
>
<div class="flex min-w-0 items-center gap-2">
<span class="h-3 w-3 rounded-full bg-[#2b7fff]"></span>
<span class="truncate text-sm font-semibold">星火应用商店</span>
</div>
<div class="window-titlebar-controls flex items-center gap-1">
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
aria-label="最小化"
title="最小化"
@click="minimize"
>
</button>
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-slate-200/80 dark:hover:bg-slate-800"
aria-label="最大化或还原"
title="最大化或还原"
@click="toggleMaximize"
>
</button>
<button
type="button"
class="rounded-md px-3 py-1 text-sm transition hover:bg-red-500 hover:text-white"
aria-label="关闭"
title="关闭"
@click="close"
>
×
</button>
</div>
</header>
</template>
<script setup lang="ts">
const minimize = () => {
window.windowControls.minimize();
};
const toggleMaximize = () => {
window.windowControls.toggleMaximize();
};
const close = () => {
window.windowControls.close();
};
</script>
<style scoped>
.window-titlebar {
-webkit-app-region: drag;
}
.window-titlebar-controls,
.window-titlebar-controls button {
-webkit-app-region: no-drag;
}
</style>
+39
View File
@@ -0,0 +1,39 @@
import { ref, watch } from "vue";
const INSTALLED_SYNC_STORAGE_KEY = "spark-store-installed-sync-enabled";
let activeSyncUserId: number | null = null;
const syncStorageKey = (userId: number | null): string =>
userId === null
? INSTALLED_SYNC_STORAGE_KEY
: `${INSTALLED_SYNC_STORAGE_KEY}:${userId}`;
const readSyncEnabled = (userId: number | null): boolean | null => {
const savedValue = localStorage.getItem(syncStorageKey(userId));
if (savedValue === "true") return true;
if (savedValue === "false") return false;
return null;
};
export const installedSyncEnabled = ref<boolean | null>(
readSyncEnabled(activeSyncUserId),
);
export const loadInstalledSyncPreference = (userId: number | null): void => {
activeSyncUserId = userId;
installedSyncEnabled.value = readSyncEnabled(userId);
};
export const setInstalledSyncEnabled = (enabled: boolean): void => {
installedSyncEnabled.value = enabled;
localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled));
};
watch(installedSyncEnabled, (enabled) => {
if (enabled === null) {
localStorage.removeItem(syncStorageKey(activeSyncUserId));
return;
}
localStorage.setItem(syncStorageKey(activeSyncUserId), String(enabled));
});
+63
View File
@@ -0,0 +1,63 @@
import { computed, ref } from "vue";
import { setBackendToken } from "@/modules/backendApi";
import type { AuthSession, SparkUser } from "./typedefinition";
const AUTH_STORAGE_KEY = "spark-store-auth";
const isSparkUser = (value: unknown): value is SparkUser => {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const user = value as Record<string, unknown>;
return (
typeof user.id === "number" &&
typeof user.flarumUserId === "string" &&
typeof user.username === "string" &&
typeof user.displayName === "string" &&
typeof user.avatarUrl === "string" &&
(user.coverUrl === undefined || typeof user.coverUrl === "string") &&
typeof user.forumLevel === "string" &&
Array.isArray(user.forumGroups) &&
user.forumGroups.every((group) => typeof group === "string")
);
};
const isAuthSession = (value: unknown): value is AuthSession => {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const session = value as Record<string, unknown>;
return (
typeof session.accessToken === "string" &&
session.accessToken.length > 0 &&
session.tokenType === "bearer" &&
isSparkUser(session.user)
);
};
const loadStoredSession = (): AuthSession | null => {
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
return isAuthSession(parsed) ? parsed : null;
} catch {
return null;
}
};
export const authSession = ref<AuthSession | null>(loadStoredSession());
export const currentUser = computed(() => authSession.value?.user ?? null);
export const isLoggedIn = computed(() => authSession.value !== null);
setBackendToken(authSession.value?.accessToken ?? null);
export const setAuthSession = (session: AuthSession): void => {
authSession.value = session;
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
setBackendToken(session.accessToken);
};
export const logout = (): void => {
authSession.value = null;
localStorage.removeItem(AUTH_STORAGE_KEY);
setBackendToken(null);
};
+14
View File
@@ -7,6 +7,20 @@ export const APM_STORE_BASE_URL: string =
export const APM_STORE_STATS_BASE_URL: string =
import.meta.env.VITE_APM_STORE_STATS_BASE_URL || "";
export const DEFAULT_SPARK_BACKEND_BASE_URL = "http://127.0.0.1:8000";
export const SPARK_BACKEND_BASE_URL: string =
import.meta.env.VITE_SPARK_BACKEND_BASE_URL || DEFAULT_SPARK_BACKEND_BASE_URL;
export const SPARK_ACCOUNT_CENTER_URL: string =
import.meta.env.VITE_SPARK_ACCOUNT_CENTER_URL ||
"https://account.spark-app.store/account";
export const FLARUM_BASE_URL = "https://bbs.spark-app.store";
export const FLARUM_REGISTER_URL = `${FLARUM_BASE_URL}/register`;
export const FLARUM_PROFILE_URL = `${FLARUM_BASE_URL}/u`;
export const FLARUM_SETTINGS_URL = `${FLARUM_BASE_URL}/settings`;
// 下面的变量用于存储当前应用的信息,其实用在多个组件中
export const currentApp = ref<App | null>(null);
export const currentAppSparkInstalled = ref(false);
+168
View File
@@ -241,3 +241,171 @@ export interface HomeList {
title: string;
apps: App[];
}
export interface SidebarEntry {
id: string;
name: string;
icon?: string;
type?: "category" | "search" | "link";
value?: string;
}
export interface SparkUser {
id: number;
flarumUserId: string;
username: string;
displayName: string;
avatarUrl: string;
coverUrl?: string;
forumLevel: string;
forumGroups: string[];
}
export interface ReviewUserProfile {
displayName: string;
username?: string;
avatarUrl?: string;
coverUrl?: string;
forumGroups?: string[];
}
export interface AuthSession {
accessToken: string;
tokenType: "bearer";
user: SparkUser;
}
export interface FlarumLoginPayload {
identification: string;
password: string;
}
export interface ReviewTags {
origin: "spark" | "apm";
category: string;
pkgname: string;
version: string;
packageArch: string;
clientArch: string;
distro: string;
}
export interface RatingSummary {
averageRating: number;
reviewCount: number;
starCounts: Record<number, number>;
}
export interface AppReviewReply {
id: number;
reviewId: number;
parentId: number | null;
content: string;
createdAt: string;
updatedAt: string;
userDisplayName: string;
userAvatarUrl: string;
likeCount: number;
likedByCurrentUser: boolean;
canDelete: boolean;
isAuthor: boolean;
isDeleted: boolean;
replies: AppReviewReply[];
}
export interface AppReview {
id: number;
userId?: number;
rating: number;
content: string;
version: string;
packageArch: string;
clientArch: string;
distro: string;
origin: "spark" | "apm";
category: string;
createdAt: string;
updatedAt: string;
userDisplayName: string;
userAvatarUrl: string;
likeCount?: number;
likedByCurrentUser?: boolean;
canDelete?: boolean;
isAuthor?: boolean;
isDeleted?: boolean;
replies?: AppReviewReply[];
}
export interface FavoriteFolder {
id: number;
name: string;
itemCount: number;
createdAt: string;
updatedAt: string;
}
export interface FavoriteItem {
id: number;
appKey: string;
pkgname: string;
name: string;
category: string;
iconUrl: string;
createdAt: string;
}
export type FavoriteAvailabilityStatus =
| "installable"
| "installed"
| "platform-unavailable"
| "arch-unavailable"
| "downlisted";
export interface ResolvedFavoriteItem {
item: FavoriteItem;
status: FavoriteAvailabilityStatus;
reason: string;
selectedApp: App | null;
}
export interface DownloadedAppRecord {
id: number;
appKey: string;
pkgname: string;
name: string;
category: string;
selectedOrigin: "spark" | "apm";
version: string;
packageArch: string;
downloadedAt: string;
}
export interface DownloadedAppList {
items: DownloadedAppRecord[];
total: number;
page: number;
pageSize: number;
}
export interface SyncedAppListItem {
id?: number;
pkgname: string;
origin: "spark" | "apm";
category: string;
version: string;
packageArch: string;
appName: string;
iconUrl: string;
}
export interface SyncedAppList {
snapshotName: string;
clientArch: string;
distro: string;
updatedAt: string;
items: SyncedAppListItem[];
}
export interface SystemInfo {
distro: string;
}
+26
View File
@@ -0,0 +1,26 @@
export const FALLBACK_ACCOUNT_CENTER_URL =
"https://account.spark-app.store/account";
export const buildAccountFrameUrl = (
baseUrl: string,
username: string,
): string => {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
url = new URL(FALLBACK_ACCOUNT_CENTER_URL);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
url = new URL(FALLBACK_ACCOUNT_CENTER_URL);
}
const allowedParams = new URLSearchParams();
allowedParams.set("view", "management");
allowedParams.set("user", username);
url.search = allowedParams.toString();
return url.toString();
};
+45
View File
@@ -0,0 +1,45 @@
import type { App, ReviewTags } from "@/global/typedefinition";
export const parsePackageArch = (filename: string): string => {
const match = filename.match(/_([^_]+)\.deb$/);
return match?.[1] || "unknown";
};
export const buildStoreArch = (
origin: "spark" | "apm",
clientArch: string,
): string => {
const rawArch = clientArch.replace(/-(store|apm)$/, "");
return `${rawArch}-${origin === "spark" ? "store" : "apm"}`;
};
export const buildFavoriteAppKey = (app: App): string => {
return `app:${app.category || "unknown"}:${app.pkgname}`;
};
export const buildReviewAppKey = (app: App, clientArch: string): string => {
return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category || "unknown"}:${app.pkgname}`;
};
export const getDisplayApp = (app: App | null): App | null => {
if (!app) return null;
if (!app.isMerged) return app;
if (app.viewingOrigin === "spark") return app.sparkApp ?? app.apmApp ?? app;
if (app.viewingOrigin === "apm") return app.apmApp ?? app.sparkApp ?? app;
return app.sparkApp ?? app.apmApp ?? app;
};
export const buildReviewTags = (
app: App,
options: { clientArch: string; distro: string },
): ReviewTags => {
return {
origin: app.origin,
category: app.category || "unknown",
pkgname: app.pkgname,
version: app.version,
packageArch: parsePackageArch(app.filename),
clientArch: options.clientArch,
distro: options.distro,
};
};
+76
View File
@@ -0,0 +1,76 @@
import type { App, SyncedAppListItem } from "@/global/typedefinition";
import { parsePackageArch } from "@/modules/appIdentity";
const hasUsablePackageIdentity = (app: App): boolean => {
return app.pkgname.trim().length > 0 && Boolean(app.origin);
};
export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => {
return apps
.filter(
(app) =>
app.currentStatus === "installed" &&
app.category !== "unknown" &&
!app.isDependency &&
hasUsablePackageIdentity(app),
)
.map((app) => ({
pkgname: app.pkgname,
origin: app.origin,
category: app.category,
version: app.version,
packageArch: app.arch || parsePackageArch(app.filename),
appName: app.name || app.pkgname,
iconUrl: app.icons || "",
}));
};
export const cloudItemKey = (
item: Pick<SyncedAppListItem, "origin" | "pkgname">,
): string => `${item.origin}:${item.pkgname}`;
export const cloudPackageKey = (
item: Pick<SyncedAppListItem, "pkgname">,
): string => item.pkgname;
export const mergeInstalledApps = (
currentApps: App[],
refreshedApps: App[],
refreshedOrigins: Array<"spark" | "apm">,
): App[] => {
const refreshedKeys = new Set(
refreshedApps.map((app) => `${app.origin}:${app.pkgname}`),
);
return [
...currentApps.filter(
(app) =>
!refreshedOrigins.includes(app.origin) &&
!refreshedKeys.has(`${app.origin}:${app.pkgname}`),
),
...refreshedApps,
];
};
export const resolveCloudInstallCandidate = (
item: SyncedAppListItem,
apps: App[],
): App | null => {
const exactMatch = apps.find(
(app) =>
app.pkgname === item.pkgname &&
app.origin === item.origin &&
app.category === item.category,
);
const sameOriginPackageMatch = apps.find(
(app) => app.pkgname === item.pkgname && app.origin === item.origin,
);
return (
exactMatch ??
sameOriginPackageMatch ??
apps.find((app) => app.pkgname === item.pkgname) ??
null
);
};
+545
View File
@@ -0,0 +1,545 @@
import axios, { type AxiosResponse } from "axios";
import pino from "pino";
import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig";
import type {
AppReview,
AppReviewReply,
AuthSession,
DownloadedAppList,
DownloadedAppRecord,
FavoriteFolder,
FavoriteItem,
RatingSummary,
ReviewTags,
SparkUser,
SyncedAppList,
SyncedAppListItem,
} from "@/global/typedefinition";
const backend = axios.create({
baseURL: SPARK_BACKEND_BASE_URL,
timeout: 10000,
});
const logger = pino({ name: "backendApi" });
type ApiRecord = Record<string, unknown>;
const normalizeBackendAuthError = (error: unknown): Error => {
if (!axios.isAxiosError(error)) {
return error instanceof Error ? error : new Error("登录失败,请稍后重试。");
}
logger.error(
{
code: error.code,
message: error.message,
status: error.response?.status,
},
"Spark backend auth exchange failed",
);
if (!error.response) {
return new Error("无法连接星火账号服务,请确认后端服务已启动或稍后重试。");
}
const status = error.response.status;
if (status === 401) {
return new Error("论坛登录失败,请检查账号和密码。");
}
if (status === 503) {
return new Error("星火账号服务暂时无法连接论坛,请稍后重试。");
}
if (status === 500) {
return new Error("星火账号服务异常,请确认后端数据库迁移已执行后重试。");
}
return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`);
};
const normalizeBackendMutationError = (error: unknown): Error => {
if (!axios.isAxiosError(error)) {
return error instanceof Error ? error : new Error("操作失败,请稍后重试。");
}
logger.error(
{
code: error.code,
message: error.message,
status: error.response?.status,
},
"Spark backend mutation failed",
);
if (!error.response) {
return new Error("无法连接星火账号服务,请稍后重试。");
}
const status = error.response.status;
if (status === 401) {
return new Error("登录状态已失效,请重新登录星火账号。");
}
if (status === 403) {
return new Error("请登录星火账号后重试。");
}
if (status === 422) {
return new Error("提交内容格式不正确,请检查后重试。");
}
if (status >= 500) {
return new Error("星火账号服务异常,请稍后重试。");
}
return new Error(`星火账号服务返回异常 (${status}),请稍后重试。`);
};
const asApiRecord = (value: unknown): ApiRecord => {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as ApiRecord;
}
return {};
};
const asApiRecordArray = (value: unknown): ApiRecord[] => {
if (!Array.isArray(value)) return [];
return value.map(asApiRecord);
};
export const parseForumGroups = (raw: unknown): string[] => {
if (Array.isArray(raw)) {
return raw.filter((item): item is string => typeof item === "string");
}
if (typeof raw !== "string" || raw.length === 0) return [];
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return [];
}
};
const toUser = (raw: ApiRecord): SparkUser => ({
id: Number(raw.id),
flarumUserId: String(raw.flarum_user_id || ""),
username: String(raw.username || ""),
displayName: String(raw.display_name || raw.username || ""),
avatarUrl: String(raw.avatar_url || ""),
coverUrl: String(raw.cover_url || raw.coverUrl || "") || undefined,
forumLevel: String(raw.forum_level || "论坛用户"),
forumGroups: parseForumGroups(raw.forum_groups),
});
const toReview = (raw: ApiRecord): AppReview => ({
id: Number(raw.id),
userId: raw.user_id === undefined ? undefined : Number(raw.user_id),
rating: Number(raw.rating),
content: String(raw.content || ""),
version: String(raw.version || "unknown"),
packageArch: String(raw.package_arch || "unknown"),
clientArch: String(raw.client_arch || "unknown"),
distro: String(raw.distro || "unknown"),
origin: raw.origin === "spark" ? "spark" : "apm",
category: String(raw.category || ""),
createdAt: String(raw.created_at || ""),
updatedAt: String(raw.updated_at || ""),
userDisplayName: String(raw.user_display_name || ""),
userAvatarUrl: String(raw.user_avatar_url || ""),
likeCount: Number(raw.like_count || 0),
likedByCurrentUser: Boolean(raw.liked_by_current_user),
canDelete: raw.can_delete === undefined ? undefined : Boolean(raw.can_delete),
isAuthor: raw.is_author === undefined ? undefined : Boolean(raw.is_author),
isDeleted: Boolean(raw.is_deleted),
replies: asApiRecordArray(raw.replies).map(toReviewReply),
});
const toReviewReply = (raw: ApiRecord): AppReviewReply => ({
id: Number(raw.id),
reviewId: Number(raw.review_id),
parentId:
raw.parent_id === null || raw.parent_id === undefined
? null
: Number(raw.parent_id),
content: String(raw.content || ""),
createdAt: String(raw.created_at || ""),
updatedAt: String(raw.updated_at || ""),
userDisplayName: String(raw.user_display_name || ""),
userAvatarUrl: String(raw.user_avatar_url || ""),
likeCount: Number(raw.like_count || 0),
likedByCurrentUser: Boolean(raw.liked_by_current_user),
canDelete: Boolean(raw.can_delete),
isAuthor: Boolean(raw.is_author),
isDeleted: Boolean(raw.is_deleted),
replies: asApiRecordArray(raw.replies).map(toReviewReply),
});
export interface ReviewActionState {
likedByCurrentUser: boolean;
likeCount: number;
}
export interface CreateReviewReplyPayload {
content: string;
parentId?: number;
}
const toReviewActionState = (raw: ApiRecord): ReviewActionState => ({
likedByCurrentUser: Boolean(raw.liked_by_current_user),
likeCount: Number(raw.like_count || 0),
});
const toFavoriteFolder = (raw: ApiRecord): FavoriteFolder => ({
id: Number(raw.id),
name: String(raw.name || ""),
itemCount: Number(raw.item_count || 0),
createdAt: String(raw.created_at || ""),
updatedAt: String(raw.updated_at || ""),
});
const toFavoriteItem = (raw: ApiRecord): FavoriteItem => ({
id: Number(raw.id),
appKey: String(raw.app_key || ""),
pkgname: String(raw.pkgname || ""),
name: String(raw.name || ""),
category: String(raw.category || ""),
iconUrl: String(raw.icon_url || ""),
createdAt: String(raw.created_at || ""),
});
const toDownloadedApp = (raw: ApiRecord): DownloadedAppRecord => ({
id: Number(raw.id),
appKey: String(raw.app_key || ""),
pkgname: String(raw.pkgname || ""),
name: String(raw.name || ""),
category: String(raw.category || ""),
selectedOrigin: raw.selected_origin === "spark" ? "spark" : "apm",
version: String(raw.version || ""),
packageArch: String(raw.package_arch || "unknown"),
downloadedAt: String(raw.downloaded_at || ""),
});
const toSyncedAppListItem = (raw: ApiRecord): SyncedAppListItem => ({
id: raw.id === undefined ? undefined : Number(raw.id),
pkgname: String(raw.pkgname || ""),
origin: raw.origin === "spark" ? "spark" : "apm",
category: String(raw.category || ""),
version: String(raw.version || ""),
packageArch: String(raw.package_arch || "unknown"),
appName: String(raw.app_name || ""),
iconUrl: String(raw.icon_url || ""),
});
const toSyncedAppList = (
raw: ApiRecord,
fallback?: { clientArch: string; distro: string; items: SyncedAppListItem[] },
): SyncedAppList => ({
snapshotName: String(raw.snapshot_name || "默认列表"),
clientArch: String(raw.client_arch || fallback?.clientArch || "unknown"),
distro: String(raw.distro || fallback?.distro || "unknown"),
updatedAt: String(raw.updated_at || ""),
items: raw.items
? asApiRecordArray(raw.items).map(toSyncedAppListItem)
: fallback?.items || [],
});
export const setBackendToken = (token: string | null): void => {
const backendWithOptionalDefaults = backend as typeof backend & {
defaults?: { headers?: { common?: Record<string, unknown> } };
};
const commonHeaders = backendWithOptionalDefaults.defaults?.headers?.common;
if (!commonHeaders) return;
if (token) commonHeaders.Authorization = `Bearer ${token}`;
else delete commonHeaders.Authorization;
};
export const exchangeFlarumToken = async (payload: {
flarumUserId: string;
flarumToken: string;
}): Promise<AuthSession> => {
let response: AxiosResponse;
try {
response = await backend.post("/auth/flarum", {
flarum_user_id: payload.flarumUserId,
flarum_token: payload.flarumToken,
});
} catch (error) {
throw normalizeBackendAuthError(error);
}
const data = asApiRecord(response.data);
return {
accessToken: String(data.access_token || ""),
tokenType: "bearer",
user: toUser(asApiRecord(data.user)),
};
};
export const fetchMe = async (): Promise<SparkUser> => {
const response = await backend.get("/me");
return toUser(asApiRecord(response.data));
};
export const fetchRatingSummary = async (
appKey: string,
): Promise<RatingSummary> => {
const response = await backend.get(
`/apps/${encodeURIComponent(appKey)}/rating-summary`,
);
const data = asApiRecord(response.data);
return {
averageRating: Number(data.average_rating || 0),
reviewCount: Number(data.review_count || 0),
starCounts: Object.fromEntries(
Object.entries(asApiRecord(data.star_counts)).map(([key, value]) => [
Number(key),
Number(value),
]),
),
};
};
export const fetchReviews = async (appKey: string): Promise<AppReview[]> => {
const response = await backend.get(
`/apps/${encodeURIComponent(appKey)}/reviews`,
);
return asApiRecordArray(response.data).map(toReview);
};
export const submitReview = async (
appKey: string,
payload: { rating: number; content: string; tags: ReviewTags },
): Promise<AppReview> => {
let response: AxiosResponse;
try {
response = await backend.post(
`/apps/${encodeURIComponent(appKey)}/reviews`,
{
rating: payload.rating,
content: payload.content,
tags: {
origin: payload.tags.origin,
category: payload.tags.category,
pkgname: payload.tags.pkgname,
version: payload.tags.version,
package_arch: payload.tags.packageArch,
client_arch: payload.tags.clientArch,
distro: payload.tags.distro,
},
},
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
return toReview(asApiRecord(response.data));
};
export const likeReview = async (
appKey: string,
reviewId: number,
): Promise<ReviewActionState> => {
let response: AxiosResponse;
try {
response = await backend.post(
`/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/like`,
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
return toReviewActionState(asApiRecord(response.data));
};
export const createReviewReply = async (
appKey: string,
reviewId: number,
payload: CreateReviewReplyPayload,
): Promise<AppReviewReply> => {
let response: AxiosResponse;
try {
response = await backend.post(
`/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies`,
{
content: payload.content,
...(payload.parentId === undefined
? {}
: { parent_id: payload.parentId }),
},
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
return toReviewReply(asApiRecord(response.data));
};
export const deleteReview = async (
appKey: string,
reviewId: number,
): Promise<void> => {
try {
await backend.delete(
`/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}`,
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
};
export const likeReviewReply = async (
appKey: string,
reviewId: number,
replyId: number,
): Promise<ReviewActionState> => {
let response: AxiosResponse;
try {
response = await backend.post(
`/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies/${replyId}/like`,
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
return toReviewActionState(asApiRecord(response.data));
};
export const deleteReviewReply = async (
appKey: string,
reviewId: number,
replyId: number,
): Promise<void> => {
try {
await backend.delete(
`/apps/${encodeURIComponent(appKey)}/reviews/${reviewId}/replies/${replyId}`,
);
} catch (error) {
throw normalizeBackendMutationError(error);
}
};
export const listFavoriteFolders = async (): Promise<FavoriteFolder[]> => {
const response = await backend.get("/me/favorite-folders");
return asApiRecordArray(response.data).map(toFavoriteFolder);
};
export const createFavoriteFolder = async (
name: string,
): Promise<FavoriteFolder> => {
const response = await backend.post("/me/favorite-folders", { name });
return toFavoriteFolder(asApiRecord(response.data));
};
export const renameFavoriteFolder = async (
folderId: number,
name: string,
): Promise<FavoriteFolder> => {
const response = await backend.patch(`/me/favorite-folders/${folderId}`, {
name,
});
return toFavoriteFolder(asApiRecord(response.data));
};
export const deleteFavoriteFolder = async (folderId: number): Promise<void> => {
await backend.delete(`/me/favorite-folders/${folderId}`);
};
export const listFavoriteItems = async (
folderId: number,
): Promise<FavoriteItem[]> => {
const response = await backend.get(`/me/favorite-folders/${folderId}/items`);
return asApiRecordArray(response.data).map(toFavoriteItem);
};
export const addFavoriteItem = async (
folderId: number | "default",
item: Omit<FavoriteItem, "id" | "createdAt">,
): Promise<FavoriteItem> => {
const response = await backend.post(
`/me/favorite-folders/${folderId}/items`,
{
app_key: item.appKey,
pkgname: item.pkgname,
name: item.name,
category: item.category,
icon_url: item.iconUrl,
},
);
return toFavoriteItem(asApiRecord(response.data));
};
export const deleteFavoriteItem = async (
folderId: number,
itemId: number,
): Promise<void> => {
await backend.delete(`/me/favorite-folders/${folderId}/items/${itemId}`);
};
export const bulkDeleteFavoriteItems = async (
folderId: number,
itemIds: number[],
): Promise<number> => {
const response = await backend.post(
`/me/favorite-folders/${folderId}/items/bulk-delete`,
{ item_ids: itemIds },
);
return Number(asApiRecord(response.data).deleted_count || 0);
};
export const listDownloadedApps = async (
page = 1,
pageSize = 20,
): Promise<DownloadedAppList> => {
const response = await backend.get("/me/downloaded-apps", {
params: { page, page_size: pageSize },
});
const data = asApiRecord(response.data);
return {
items: asApiRecordArray(data.items).map(toDownloadedApp),
total: Number(data.total || 0),
page: Number(data.page || page),
pageSize: Number(data.page_size || pageSize),
};
};
export const recordDownloadedApp = async (
item: Omit<DownloadedAppRecord, "id" | "downloadedAt">,
): Promise<DownloadedAppRecord> => {
const response = await backend.post("/me/downloaded-apps", {
app_key: item.appKey,
pkgname: item.pkgname,
name: item.name,
category: item.category,
selected_origin: item.selectedOrigin,
version: item.version,
package_arch: item.packageArch,
});
return toDownloadedApp(asApiRecord(response.data));
};
export const fetchSyncedAppList = async (): Promise<SyncedAppList | null> => {
const response = await backend.get("/me/app-list");
if (!response.data) return null;
return toSyncedAppList(asApiRecord(response.data));
};
export const uploadSyncedAppList = async (payload: {
clientArch: string;
distro: string;
items: SyncedAppListItem[];
}): Promise<SyncedAppList> => {
const response = await backend.put("/me/app-list", {
client_arch: payload.clientArch,
distro: payload.distro,
items: payload.items.map((item) => ({
pkgname: item.pkgname,
origin: item.origin,
category: item.category,
version: item.version,
package_arch: item.packageArch,
app_name: item.appName,
icon_url: item.iconUrl,
})),
});
return toSyncedAppList(asApiRecord(response.data), payload);
};

Some files were not shown because too many files have changed in this diff Show More