10 Commits
Author SHA1 Message Date
OpenClaw Agent 9efd9a3099 fix: 金额计算展示保留原始数据精度,去除 toFixed(1) 进位截断并升级至 v1.0.3
Build Android APK / Build Release APK (push) Canceled after 0s
Sync Release Notes / update-releases (push) Canceled after 0s
2026-09-16 03:04:10 +08:00
OpenClaw Agent a8ece73038 ci: 自动化提取 CHANGELOG.md 作为 GitHub Release 描述,并同步历史 Release Notes
Sync Release Notes / update-releases (push) Canceled after 0s
2026-09-15 06:28:57 +08:00
OpenClaw Agent 0d5d200844 docs: 在 README 中补充版本发布历史 (Changelog) 记录 v1.0.0 ~ v1.0.2 2026-09-15 06:13:36 +08:00
OpenClaw Agent d7b2c5e49a fix: 退出登录时联动重置 MCP 状态并禁用未登录操作,升级至 v1.0.2
Build Android APK / Build Release APK (push) Canceled after 0s
2026-09-15 05:43:48 +08:00
OpenClaw Agent e51ada9d40 feat: 统一应用名为工时打卡,优化设置页与注册登录交互,升级至 v1.0.1
Build Android APK / Build Release APK (push) Canceled after 0s
2026-09-15 05:25:58 +08:00
OpenClaw Agent e518f80a77 fix(android): align kotlin-stdlib artifacts using kotlin-bom:1.8.22 2026-09-15 02:59:05 +08:00
OpenClaw Agent fe13dd7030 fix(android): upgrade appcompat to 1.7.0 and activity to 1.9.0 to align kotlin-stdlib 2026-09-15 02:56:46 +08:00
OpenClaw Agent fb01b61893 fix(android): enable AndroidX and modern plugins DSL 2026-09-15 02:47:06 +08:00
OpenClaw Agent e8739f38f3 fix(android): remove redundant allprojects repos and upgrade action versions 2026-09-15 02:39:01 +08:00
OpenClaw Agent 3fca8c5de9 ci: add contents:write permission for automated releases 2026-09-15 02:11:35 +08:00
17 changed files with 571 additions and 92 deletions
+24 -2
View File
@@ -15,19 +15,21 @@ jobs:
build: build:
name: Build Release APK name: Build Release APK
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
- name: Sync Web Assets to Android - name: Sync Web Assets to Android
run: | run: |
@@ -97,12 +99,32 @@ jobs:
path: ${{ steps.prepare_apk.outputs.apk_path }} path: ${{ steps.prepare_apk.outputs.apk_path }}
retention-days: 30 retention-days: 30
- name: Extract Release Notes from CHANGELOG
if: startsWith(github.ref, 'refs/tags/v')
run: |
python3 -c "
import re
version = '${{ steps.prepare_apk.outputs.version }}'
tag = 'v' + version if not version.startswith('v') else version
try:
with open('CHANGELOG.md', 'r', encoding='utf-8') as f:
content = f.read()
pattern = r'### ' + re.escape(tag) + r'[^\n]*\n(.*?)(?=\n### v|\Z)'
m = re.search(pattern, content, re.DOTALL)
notes = m.group(1).strip() if m else ''
except Exception:
notes = ''
with open('current_release_notes.md', 'w', encoding='utf-8') as f:
f.write(notes)
"
- name: Create GitHub Release - name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: ${{ steps.prepare_apk.outputs.apk_path }} files: ${{ steps.prepare_apk.outputs.apk_path }}
name: Work Hours Tracker v${{ steps.prepare_apk.outputs.version }} name: Work Hours Tracker v${{ steps.prepare_apk.outputs.version }}
body_path: current_release_notes.md
draft: false draft: false
prerelease: false prerelease: false
generate_release_notes: true generate_release_notes: true
+68
View File
@@ -0,0 +1,68 @@
name: Sync Release Notes
on:
workflow_dispatch:
push:
paths:
- 'CHANGELOG.md'
- '.github/workflows/sync-releases.yml'
jobs:
update-releases:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Update GitHub Releases
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
python3 -c "
import os, urllib.request, json, re
token = os.environ['GITHUB_TOKEN']
repo = os.environ['REPO']
headers = {
'Authorization': f'Bearer {token}',
'Accept': 'application/vnd.github+json',
'User-Agent': 'Actions-Release-Syncer'
}
# 1. 解析 CHANGELOG.md 中的各版本内容
with open('CHANGELOG.md', 'r', encoding='utf-8') as f:
content = f.read()
pattern = r'### (v\d+\.\d+\.\d+)[^\n]*\n(.*?)(?=\n### v|\Z)'
matches = re.findall(pattern, content, re.DOTALL)
changelogs = {tag: body.strip() for tag, body in matches}
print('Found changelogs for tags:', list(changelogs.keys()))
# 2. 获取现有的 releases
url = f'https://api.github.com/repos/{repo}/releases'
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req) as resp:
releases = json.loads(resp.read().decode())
# 3. 逐个更新 release body
for r in releases:
tag = r.get('tag_name')
rel_id = r.get('id')
if tag in changelogs:
new_body = changelogs[tag]
# 补全 compare 链接
if 'Full Changelog' not in new_body:
new_body += f'\n\n---\n**Full Changelog**: https://github.com/{repo}/commits/{tag}'
update_url = f'https://api.github.com/repos/{repo}/releases/{rel_id}'
data = json.dumps({'body': new_body}).encode('utf-8')
up_req = urllib.request.Request(update_url, data=data, headers=headers, method='PATCH')
with urllib.request.urlopen(up_req) as up_resp:
print(f'Successfully updated release {tag} (ID: {rel_id})')
else:
print(f'No changelog found for tag {tag}')
"
+1
View File
@@ -39,3 +39,4 @@ android/app/src/main/assets/www/*
.DS_Store .DS_Store
*.tmp *.tmp
*.apk *.apk
keystore-secure/
+50
View File
@@ -0,0 +1,50 @@
## 七、 版本发布历史 (Changelog)
### v1.0.3 (2026-09-16)
- **修复 (金额精度保留与无损展示)**:
- 去除金额展示层原有的 `toFixed(1)` 进位截断,彻底解决如 `103.5h × ¥19.5/h = ¥2018.25` 在看板与汇总中被四舍五入进位显示为 `¥2018.3` 的问题。
- 金额计算与格式化逻辑(`money`、`signed`、`moneyParts`)调整为保留原始数据有效位数,同时消除 JS 浮点二进制精度抖动。
- 看板大字整数与小数拆分逻辑自适应原始小数位,保证整数部千分位与小数分位严密吻合。
- **Android 原生资产与版本**:
- 同步静态资产至 `android/app/src/main/assets/www/`。
- 版本号升级:`versionCode 4`,`versionName "1.0.3"`。
### v1.0.2 (2026-09-15)
- **修复 (MCP 状态联动与生命周期)**:
- 修复退出登录后前端 `mcp` 响应式对象残留已开启状态、点击关闭弹出「未登录」错误的问题。
- 新增 `resetMcpState(authNeeded)` 函数,在 `doLogout` 成功退出时联动重置 MCP 状态并清空掩码。
- `loadMcpStatus` 增加登录凭证前置检测与 401/403 异常拦截重置;在 `doLogin` 成功后自动拉取当前账号的专属 MCP 状态。
- 未登录时 MCP 接入开关与生成令牌按钮自动置灰禁用,右上角徽章明确显示「本设备请先登录」,`toggleMcp` 增加前置拦截提示。
- **Android 原生资产与版本**:
- 静态资产同步至 `android/app/src/main/assets/www/`。
- 版本号升级:`versionCode 3`,`versionName "1.0.2"`。
### v1.0.1 (2026-09-15)
- **应用名称统一**:
- Android 原生应用名称统一由「工时记账」修改为「工时打卡」(`strings.xml`)。
- `MainActivity.java` 连续双击返回退出提示文案同步更新为「再按一次退出工时打卡」。
- **默认远程服务器探活与回退**:
- `sync.js` 预设 `DEFAULT_REMOTE_BASE = 'https://daka.kory.kdns.fr'`。
- 深度适配 Android WebView `file:///android_asset/...` 环境,未手动指定服务器地址时自动探活并回退到默认远程服务器。
- **设置页交互分流与体验重构**:
- 底部导航栏点击「设置」原生切换至 `<section v-show="tab === 'settings'">` 独立全屏视图,保持打卡、汇总、设置三页平级体验。
- 顶部导航栏齿轮图标保留快速弹出轻量半屏抽屉(`settingsOpen`)能力,满足快捷调整设置的需求。
- **登录 / 注册模式解耦**:
- 在设置页「云端同步」卡片中引入「账号登录 / 注册新账号」模式切换(`authMode`)。
- 彻底解除了等待服务端异步探测返回 `allowRegister` 按钮才浮现的延迟限制。
- **文档与发版**:
- 补充 README「六、 Android 客户端与移动端打包」章节。
- 版本号升级:`versionCode 2`,`versionName "1.0.1"`。
### v1.0.0 (2026-09-15)
- **Android 原生离线客户端支持**:
- 新增基于 Android WebView 的原生包装容器(`android/` 工程)。
- 将前端静态资产封装在本地 assets,支持完全脱网离线运行及沉浸式体验。
- **构建环境与依赖约束修复**:
- 解决 GitHub Actions CI 构建 Release APK 出现的 Kotlin stdlib 多版本冲突(`Duplicate class kotlin.collections.jdk8.CollectionsJDK8Kt`)。
- 在 `android/app/build.gradle` 引入 Kotlin BOM 平台依赖约束:`org.jetbrains.kotlin:kotlin-bom:1.8.22`。
- **CI/CD 自动化构建流水线**:
- 新增 `.github/workflows/build-apk.yml` 自动化构建工作流。
- 支持推送 `v*` 标签时自动编译、签名并发布 GitHub Release 及附带 APK 校验和。
- **系统核心基线**:
- Local-First 本地优先架构、纯函数薪酬计算引擎(`shared/payroll.js`)、启动自愈守卫、多用户数据隔离与用户级只读 MCP 服务。
+76
View File
@@ -179,3 +179,79 @@ curl -s http://127.0.0.1:9522/api/health
- 管理 API(登录态):`GET /api/mcp/status`(掩码)、`POST /api/mcp/token`(生成/重置,明文仅回一次)、 - 管理 API(登录态):`GET /api/mcp/status`(掩码)、`POST /api/mcp/token`(生成/重置,明文仅回一次)、
`GET /api/mcp/token`(复制用取回明文)、`POST /api/mcp/enabled` `GET /api/mcp/token`(复制用取回明文)、`POST /api/mcp/enabled`
- 测试:`cd server && node --disable-warning=ExperimentalWarning test/mcp.test.js`(47 项,含多用户隔离验证) - 测试:`cd server && node --disable-warning=ExperimentalWarning test/mcp.test.js`(47 项,含多用户隔离验证)
---
## 六、 Android 客户端与移动端打包
系统提供原生 Android 包装壳(WebView 容器,代码位于 `android/`),将 Web 资产打包至本地 `assets/www`,支持彻底离线断网运行与静默更新。
### 1. 原生功能特性
- **纯本地离线优先 (Local-First)**:以 `file:///android_asset/www/index.html` 启动,无网络时本地打卡与薪酬核算 100% 完整可用。
- **默认服务探活回退**:内置默认远程同步地址 `https://daka.kory.kdns.fr`,非 HTTP(S) 环境无需手动填写即可直接探活或注册/登录同步。
- **沉浸式交互与返回拦截**:双击返回键安全退出防误触(提示「再按一次退出工时打卡」),状态栏与导航栏自适应沉浸。
- **静态资源一键同步**:前端资产修改后,运行 `./android/copy-web-assets.sh` 即可自动同步至 Android 工程 assets 目录。
### 2. 本地与 CI/CD 自动构建 APK
- **本地编译**:
```bash
cd android
./gradlew assembleRelease
# 生成产物:android/app/build/outputs/apk/release/app-release-unsigned.apk
```
- **GitHub Actions 自动化发布**:
- 推送带 `v*` 格式的 Git Tag(如 `git tag v1.0.1 && git push github v1.0.1`)时,自动触发 `.github/workflows/build-apk.yml` 工作流。
- 构建产物会自动签名并发布到 GitHub Releases,附带安装包与 SHA256 校验和。
---
## 七、 版本发布历史 (Changelog)
### v1.0.3 (2026-09-16)
- **修复 (金额精度保留与无损展示)**:
- 去除金额展示层原有的 `toFixed(1)` 进位截断,彻底解决如 `103.5h × ¥19.5/h = ¥2018.25` 在看板与汇总中被四舍五入进位显示为 `¥2018.3` 的问题。
- 金额计算与格式化逻辑(`money`、`signed`、`moneyParts`)调整为保留原始数据有效位数,同时消除 JS 浮点二进制精度抖动。
- 看板大字整数与小数拆分逻辑自适应原始小数位,保证整数部千分位与小数分位严密吻合。
- **Android 原生资产与版本**:
- 同步静态资产至 `android/app/src/main/assets/www/`。
- 版本号升级:`versionCode 4`,`versionName "1.0.3"`。
### v1.0.2 (2026-09-15)
- **修复 (MCP 状态联动与生命周期)**:
- 修复退出登录后前端 `mcp` 响应式对象残留已开启状态、点击关闭弹出「未登录」错误的问题。
- 新增 `resetMcpState(authNeeded)` 函数,在 `doLogout` 成功退出时联动重置 MCP 状态并清空掩码。
- `loadMcpStatus` 增加登录凭证前置检测与 401/403 异常拦截重置;在 `doLogin` 成功后自动拉取当前账号的专属 MCP 状态。
- 未登录时 MCP 接入开关与生成令牌按钮自动置灰禁用,右上角徽章明确显示「本设备请先登录」,`toggleMcp` 增加前置拦截提示。
- **Android 原生资产与版本**:
- 静态资产同步至 `android/app/src/main/assets/www/`。
- 版本号升级:`versionCode 3`,`versionName "1.0.2"`。
### v1.0.1 (2026-09-15)
- **应用名称统一**:
- Android 原生应用名称统一由「工时记账」修改为「工时打卡」(`strings.xml`)。
- `MainActivity.java` 连续双击返回退出提示文案同步更新为「再按一次退出工时打卡」。
- **默认远程服务器探活与回退**:
- `sync.js` 预设 `DEFAULT_REMOTE_BASE = 'https://daka.kory.kdns.fr'`。
- 深度适配 Android WebView `file:///android_asset/...` 环境,未手动指定服务器地址时自动探活并回退到默认远程服务器。
- **设置页交互分流与体验重构**:
- 底部导航栏点击「设置」原生切换至 `<section v-show="tab === 'settings'">` 独立全屏视图,保持打卡、汇总、设置三页平级体验。
- 顶部导航栏齿轮图标保留快速弹出轻量半屏抽屉(`settingsOpen`)能力,满足快捷调整设置的需求。
- **登录 / 注册模式解耦**:
- 在设置页「云端同步」卡片中引入「账号登录 / 注册新账号」模式切换(`authMode`)。
- 彻底解除了等待服务端异步探测返回 `allowRegister` 按钮才浮现的延迟限制。
- **文档与发版**:
- 补充 README「六、 Android 客户端与移动端打包」章节。
- 版本号升级:`versionCode 2`,`versionName "1.0.1"`。
### v1.0.0 (2026-09-15)
- **Android 原生离线客户端支持**:
- 新增基于 Android WebView 的原生包装容器(`android/` 工程)。
- 将前端静态资产封装在本地 assets,支持完全脱网离线运行及沉浸式体验。
- **构建环境与依赖约束修复**:
- 解决 GitHub Actions CI 构建 Release APK 出现的 Kotlin stdlib 多版本冲突(`Duplicate class kotlin.collections.jdk8.CollectionsJDK8Kt`)。
- 在 `android/app/build.gradle` 引入 Kotlin BOM 平台依赖约束:`org.jetbrains.kotlin:kotlin-bom:1.8.22`。
- **CI/CD 自动化构建流水线**:
- 新增 `.github/workflows/build-apk.yml` 自动化构建工作流。
- 支持推送 `v*` 标签时自动编译、签名并发布 GitHub Release 及附带 APK 校验和。
- **系统核心基线**:
- Local-First 本地优先架构、纯函数薪酬计算引擎(`shared/payroll.js`)、启动自愈守卫、多用户数据隔离与用户级只读 MCP 服务。
+13 -2
View File
@@ -10,8 +10,8 @@ android {
applicationId "top.ouaone.workhours" applicationId "top.ouaone.workhours"
minSdk 23 minSdk 23
targetSdk 34 targetSdk 34
versionCode 1 versionCode 4
versionName "1.0.0" versionName "1.0.3"
} }
signingConfigs { signingConfigs {
@@ -22,9 +22,17 @@ android {
storePassword System.getenv("KEY_STORE_PASSWORD") ?: "workhours123" storePassword System.getenv("KEY_STORE_PASSWORD") ?: "workhours123"
keyAlias System.getenv("ALIAS") ?: "workhours" keyAlias System.getenv("ALIAS") ?: "workhours"
keyPassword System.getenv("KEY_PASSWORD") ?: "workhours123" keyPassword System.getenv("KEY_PASSWORD") ?: "workhours123"
if (System.getenv("KEYSTORE_TYPE")) {
storeType System.getenv("KEYSTORE_TYPE")
} }
} }
} }
}
lint {
abortOnError false
checkReleaseBuilds false
}
buildTypes { buildTypes {
release { release {
@@ -46,6 +54,9 @@ android {
} }
dependencies { dependencies {
// 强制对齐 Kotlin 标准库依赖版本,消除 Duplicate class
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.22')
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.activity:activity:1.8.2' implementation 'androidx.activity:activity:1.8.2'
} }
@@ -117,7 +117,7 @@ public class MainActivity extends AppCompatActivity {
finish(); finish();
} else { } else {
lastBackPressTime = now; lastBackPressTime = now;
Toast.makeText(MainActivity.this, "再按一次退出工时记账", Toast.LENGTH_SHORT).show(); Toast.makeText(MainActivity.this, "再按一次退出工时打卡", Toast.LENGTH_SHORT).show();
} }
} }
}); });
+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">工时记账</string> <string name="app_name">工时打卡</string>
</resources> </resources>
+3 -16
View File
@@ -1,20 +1,7 @@
buildscript { plugins {
repositories { id 'com.android.application' version '8.2.2' apply false
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.2'
}
}
allprojects {
repositories {
google()
mavenCentral()
}
} }
task clean(type: Delete) { task clean(type: Delete) {
delete rootProject.buildDir delete rootProject.layout.buildDirectory
} }
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
+261 -17
View File
@@ -258,8 +258,8 @@
<!-- ===== 顶部资产看板 ===== --> <!-- ===== 顶部资产看板 ===== -->
<header class="text-white px-5 pt-5 pb-5 rounded-b-3xl board-shadow shrink-0 relative overflow-hidden" <header class="text-white px-5 pt-5 pb-5 rounded-b-3xl board-shadow shrink-0 relative overflow-hidden"
style="background-image: radial-gradient(125% 125% at 85% -10%, #6366F1 0%, rgba(99,102,241,0) 58%), linear-gradient(135deg, #1E293B 0%, #312E81 56%, #4F46E5 100%);"> style="background-image: radial-gradient(125% 125% at 85% -10%, #6366F1 0%, rgba(99,102,241,0) 58%), linear-gradient(135deg, #1E293B 0%, #312E81 56%, #4F46E5 100%);">
<!-- 月份切换(日历/汇总) --> <!-- 月份切换(日历/汇总) / 设置页标题 -->
<div class="flex justify-between items-center"> <div v-if="tab !== 'settings'" class="flex justify-between items-center">
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<button @click="changeMonth(-1)" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center"><svg class="ico text-base"><use href="#i-chevron-left" /></svg></button> <button @click="changeMonth(-1)" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center"><svg class="ico text-base"><use href="#i-chevron-left" /></svg></button>
<span class="text-base font-bold tracking-wide font-mono-num">{{ currentYear }}年{{ currentMonth + 1 }}月</span> <span class="text-base font-bold tracking-wide font-mono-num">{{ currentYear }}年{{ currentMonth + 1 }}月</span>
@@ -267,9 +267,12 @@
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<button v-if="!isCurrentMonth" @click="goToday" class="pill active:scale-95 transition">回今天</button> <button v-if="!isCurrentMonth" @click="goToday" class="pill active:scale-95 transition">回今天</button>
<button @click="openSettings" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center" title="设置"><svg class="ico text-base"><use href="#i-settings" /></svg></button> <button @click="openSettings" class="w-7 h-7 rounded-full hover:bg-white/20 active:scale-90 transition flex items-center justify-center" title="快速设置抽屉"><svg class="ico text-base"><use href="#i-settings" /></svg></button>
</div> </div>
</div> </div>
<div v-else class="flex items-center h-7">
<span class="text-base font-bold tracking-wide flex items-center gap-1.5"><svg class="ico text-[17px]"><use href="#i-settings" /></svg>系统设置</span>
</div>
<!-- 主数字:本月「挣得」(仅日历页)。¥ 与小数位缩小,整数部放大,形成阶梯感。 <!-- 主数字:本月「挣得」(仅日历页)。¥ 与小数位缩小,整数部放大,形成阶梯感。
注意:这里故意用归属制(本月干出来的)而不是现金流。日历页的核心动作是打卡, 注意:这里故意用归属制(本月干出来的)而不是现金流。日历页的核心动作是打卡,
@@ -657,6 +660,209 @@
</button> </button>
</section> </section>
<!-- ========== ③ 设置 ========== -->
<section v-show="tab === 'settings'" class="px-4 pt-5 pb-4 space-y-3.5">
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-coins" /></svg>薪酬规则</p>
<div class="divide-y divide-slate-100">
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">基础时薪(元/h)</span>
<input type="number" step="0.5" min="0" v-model.number="settings.rate" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">满勤天数(天)</span>
<input type="number" step="1" min="1" max="31" v-model.number="settings.fullDays" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">满勤奖(元/工时)</span>
<input type="number" step="0.5" min="0" v-model.number="settings.fullBonusPerHour" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
</div>
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">满勤奖按「当月总工时 × 单价」计算,属于奖励,不并入当月工资。</p>
</div>
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-shield-alert" /></svg>奖惩标准</p>
<div class="divide-y divide-slate-100">
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">查获违禁品(元/件)</span>
<input type="number" step="1" min="0" v-model.number="settings.foundBonus" class="w-24 text-right text-xs font-bold text-emerald-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">漏查违禁品(元/件)</span>
<input type="number" step="1" min="0" v-model.number="settings.missedPenalty" class="w-24 text-right text-xs font-bold text-rose-600 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
</div>
</div>
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-calendar-clock" /></svg>发放周期</p>
<div class="divide-y divide-slate-100">
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">上半月截止日</span>
<input type="number" step="1" min="1" max="28" v-model.number="settings.cutoff" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">上半月发放日(当月)</span>
<input type="number" step="1" min="1" max="31" v-model.number="settings.payDayA" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">下半月发放日(次月)</span>
<input type="number" step="1" min="1" max="31" v-model.number="settings.payDayB" class="w-24 text-right text-xs font-bold text-slate-800 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
</div>
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">
1–{{ settings.cutoff }}日工时 → 当月{{ settings.payDayA }}号发;{{ settings.cutoff + 1 }}日–月底工时 + 当月奖惩 → 次月{{ settings.payDayB }}号一起发。
</p>
</div>
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-hourglass" /></svg>班次默认工时</p>
<div class="divide-y divide-slate-100">
<div class="px-5 py-3 flex items-center justify-between">
<span class="text-xs text-slate-600 flex items-center gap-1.5"><svg class="ico text-[14px] text-indigo-500"><use href="#i-clock" /></svg>打卡默认班次</span>
<div class="flex rounded-xl overflow-hidden border border-slate-200">
<button @click="settings.defaultShift = 'day'"
class="px-3 py-1.5 text-[11px] font-bold transition flex items-center gap-1"
:class="cfg.defaultShift === 'day' ? 'bg-sky-600 text-white' : 'bg-white text-slate-500'">
<svg class="ico text-[13px]"><use href="#i-sun" /></svg>白班
</button>
<button @click="settings.defaultShift = 'night'"
class="px-3 py-1.5 text-[11px] font-bold transition flex items-center gap-1"
:class="cfg.defaultShift === 'night' ? 'bg-violet-600 text-white' : 'bg-white text-slate-500'">
<svg class="ico text-[13px]"><use href="#i-moon" /></svg>夜班
</button>
</div>
</div>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600 flex items-center gap-1.5"><svg class="ico text-[14px] text-amber-500"><use href="#i-sun" /></svg>白班默认(h)</span>
<input type="number" step="0.5" min="0" max="24" v-model.number="settings.dayHours" class="w-24 text-right text-xs font-bold text-sky-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500 font-mono-num" />
</label>
<label class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600 flex items-center gap-1.5"><svg class="ico text-[14px] text-violet-500"><use href="#i-moon" /></svg>夜班默认(h)</span>
<input type="number" step="0.5" min="0" max="24" v-model.number="settings.nightHours" class="w-24 text-right text-xs font-bold text-violet-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500 font-mono-num" />
</label>
</div>
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70">夜班跨天,工时全部归入开始打卡的那一天。</p>
</div>
<!-- 云端同步:未登录时也完全可用,登录只是多一层服务器备份 -->
<div class="card overflow-hidden">
<div class="px-5 py-3 bg-slate-50 border-b border-slate-100 flex items-center justify-between gap-2">
<p class="text-xs font-bold text-slate-700 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-cloud" /></svg>云端同步</p>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :class="syncStatusClass">{{ syncStatusText }}</span>
</div>
<!-- 已登录 -->
<div v-if="sync.user" class="divide-y divide-slate-100">
<div class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">当前账号</span>
<span class="text-xs font-bold text-slate-800">{{ sync.user.username }}</span>
</div>
<div v-if="syncTimeText" class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">最后同步</span>
<span class="text-xs font-bold text-slate-800 font-mono-num">{{ syncTimeText }}</span>
</div>
<div v-if="sync.lastError" class="px-5 py-2.5 bg-rose-50">
<p class="text-[10px] text-rose-700 leading-relaxed">{{ sync.lastError }}</p>
</div>
<button @click="doSyncNow" :disabled="sync.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-refresh-cw" /></svg>立即同步{{ sync.pending > 0 ? '(待上传 ' + sync.pending + ' 项)' : '' }}</button>
<button @click="reopenMerge" :disabled="sync.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-slate-500 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-shield-alert" /></svg>比对本机与服务器(有冲突时可选)</button>
<button @click="doPushAll" :disabled="sync.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-slate-500 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-upload" /></svg>把本机全部数据上传</button>
<button @click="doPullAll" :disabled="sync.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-slate-500 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-download" /></svg>用服务器数据覆盖本机</button>
<button @click="doLogout" :disabled="sync.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-log-out" /></svg>退出登录(保留本地数据)</button>
</div>
<!-- 未登录:明确区分登录与注册标签,无需等待探测延时即可直接注册 -->
<div v-else class="divide-y divide-slate-100">
<div class="px-5 pt-3 pb-1">
<div class="flex rounded-xl bg-slate-100 p-1">
<button @click="authMode = 'login'" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition"
:class="authMode === 'login' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'">账号登录</button>
<button @click="authMode = 'register'" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition"
:class="authMode === 'register' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'">注册新账号</button>
</div>
</div>
<div class="px-5 py-3 space-y-2">
<input v-model="loginForm.username" type="text" autocomplete="username" placeholder="用户名(3–32 位字母数字)"
class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" />
<input v-model="loginForm.password" type="password" autocomplete="current-password"
:placeholder="authMode === 'register' ? '设置密码(至少 8 位)' : '输入密码(至少 8 位)'"
@keyup.enter="authMode === 'register' ? doRegister() : doLogin()"
class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" />
<div class="pt-0.5">
<button v-if="authMode === 'login'" @click="doLogin" :disabled="sync.busy" class="w-full py-2.5 rounded-xl text-xs font-bold bg-indigo-600 text-white active:scale-95 transition disabled:opacity-40">立即登录</button>
<button v-else @click="doRegister" :disabled="sync.busy" class="w-full py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white active:scale-95 transition disabled:opacity-40">{{ sync.serverUsers === 0 ? '创建首个账号(管理员)' : '创建账号并登录' }}</button>
</div>
</div>
<p class="px-5 py-2.5 text-[10px] leading-relaxed bg-slate-50/70"
:class="sync.serverUsers === null ? 'text-amber-700' : 'text-slate-400'">
<template v-if="sync.serverUsers === null">没探到同步服务(当前只跑静态页)。数据仍存本机,一切功能正常;要多设备共用需先启动 server/。</template>
<template v-else-if="sync.serverUsers === 0">服务端还没有账号。创建首个账号后就会自动关闭公开注册,保障数据安全。</template>
<template v-else-if="sync.allowRegister">服务端已开放多用户注册,填写用户名密码即可直接创建账号。</template>
<template v-else>登录后打卡数据会自动同步到服务器,多设备共用同一份。未登录也能正常记账,只存本机。</template>
</p>
<label class="flex items-center justify-between gap-2 px-5 py-3">
<span class="text-xs text-slate-600 shrink-0">服务器地址</span>
<input v-model="sync.apiBase" @change="saveApiBase" type="url" inputmode="url" placeholder="留空 = 同源"
class="min-w-0 flex-1 text-right text-[11px] text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-2 py-1.5 outline-none focus:border-indigo-500" />
</label>
</div>
</div>
<!-- AI 接入 (MCP):让对话式 AI 只读查询打卡与薪酬;开关与令牌存服务器 DB,改动即时生效 -->
<div class="card overflow-hidden">
<div class="px-5 py-3 bg-slate-50 border-b border-slate-100 flex items-center justify-between gap-2">
<p class="text-xs font-bold text-slate-700 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-lightbulb" /></svg>AI 接入 (MCP)</p>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :class="mcpBadge.cls">{{ mcpBadge.text }}</span>
</div>
<div class="divide-y divide-slate-100">
<div class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">接入开关</span>
<button @click="toggleMcp" :disabled="mcp.busy || !mcp.configured || !sync.user || mcp.authNeeded" class="relative w-11 h-6 rounded-full transition shrink-0 disabled:opacity-40" :class="mcp.enabled ? 'bg-emerald-500' : 'bg-slate-300'">
<span class="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" :class="mcp.enabled ? 'left-[22px]' : 'left-0.5'"></span>
</button>
</div>
<div v-if="mcp.configured" class="flex items-center justify-between gap-2 px-5 py-3">
<span class="text-xs text-slate-600 shrink-0">访问令牌</span>
<span class="text-[11px] font-bold text-slate-700 font-mono-num">{{ mcp.masked }}</span>
</div>
<button v-if="mcp.configured" @click="copyMcpToken" :disabled="mcp.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use :href="mcpCopied ? '#i-check' : '#i-copy'" /></svg>{{ mcpCopied ? '已复制,去 AI 客户端粘贴' : '复制访问令牌(明文)' }}</button>
<button @click="newMcpToken" :disabled="mcp.busy || !sync.user" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]" :class="{ 'animate-spin': mcp.busy }"><use href="#i-refresh-cw" /></svg>{{ mcp.configured ? '重置令牌(旧令牌立即失效)' : '生成访问令牌并开启' }}</button>
</div>
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">
端点 POST /mcp(Bearer 令牌鉴权),只读:AI 只能查询出勤、工时与薪酬,改不了任何数据。开关与令牌改动即时生效,无需重启;重置后旧客户端立即失效。
</p>
</div>
<div class="card overflow-hidden">
<p class="px-5 py-3 text-xs font-bold text-slate-700 bg-slate-50 border-b border-slate-100 flex items-center gap-1.5"><svg class="ico text-[15px] text-indigo-600"><use href="#i-database" /></svg>数据</p>
<div class="divide-y divide-slate-100">
<div class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">已存打卡记录</span>
<span class="text-xs font-bold text-slate-800 font-mono-num">{{ recordCount }} 天</span>
</div>
<button @click="exportData" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use href="#i-download" /></svg>导出备份(JSON 文件)</button>
<button @click="copyData" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use :href="dataCopied ? '#i-check' : '#i-copy'" /></svg>{{ dataCopied ? '已复制到剪贴板' : '复制数据到剪贴板' }}</button>
<label class="flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition cursor-pointer">
<svg class="ico text-[15px]"><use href="#i-upload" /></svg>导入备份(同日期覆盖)
<input type="file" accept=".json,application/json" @change="importData" class="hidden" />
</label>
<button @click="resetSettings" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-slate-500 active:bg-slate-50 transition"><svg class="ico text-[15px]"><use href="#i-rotate-ccw" /></svg>恢复默认设置(不动打卡数据)</button>
<button @click="clearMonth" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition"><svg class="ico text-[15px]"><use href="#i-trash-2" /></svg>清空 {{ currentYear }}年{{ currentMonth + 1 }}月记录</button>
<button @click="clearAll" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition"><svg class="ico text-[15px]"><use href="#i-triangle-alert" /></svg>清空全部打卡记录</button>
</div>
</div>
<p class="text-center text-[10px] text-slate-400 leading-relaxed pt-1 pb-2">
<template v-if="sync.user">数据存本机浏览器,并同步到服务器账号 <b class="text-slate-500">{{ sync.user.username }}</b>。<br />换设备登录同一账号即可看到同一份数据。</template>
<template v-else>数据仅保存在本机浏览器(localStorage)。<br />清除浏览器数据会丢失,建议登录同步或定期导出备份。</template>
</p>
</section>
</main> </main>
<!-- ===== 设置抽屉 ===== --> <!-- ===== 设置抽屉 ===== -->
@@ -786,23 +992,33 @@
<button @click="doLogout" :disabled="sync.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-log-out" /></svg>退出登录(保留本地数据)</button> <button @click="doLogout" :disabled="sync.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-rose-600 active:bg-rose-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use href="#i-log-out" /></svg>退出登录(保留本地数据)</button>
</div> </div>
<!-- 未登录 --> <!-- 未登录:明确区分登录与注册标签,无需等待探测延时即可直接注册 -->
<div v-else class="divide-y divide-slate-100"> <div v-else class="divide-y divide-slate-100">
<div class="px-5 pt-3 pb-1">
<div class="flex rounded-xl bg-slate-100 p-1">
<button @click="authMode = 'login'" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition"
:class="authMode === 'login' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'">账号登录</button>
<button @click="authMode = 'register'" class="flex-1 py-1.5 text-xs font-bold rounded-lg transition"
:class="authMode === 'register' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'">注册新账号</button>
</div>
</div>
<div class="px-5 py-3 space-y-2"> <div class="px-5 py-3 space-y-2">
<input v-model="loginForm.username" type="text" autocomplete="username" placeholder="用户名(3–32 位字母数字)" <input v-model="loginForm.username" type="text" autocomplete="username" placeholder="用户名(3–32 位字母数字)"
class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" /> class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" />
<input v-model="loginForm.password" type="password" autocomplete="current-password" placeholder="密码(至少 8 位)" @keyup.enter="doLogin" <input v-model="loginForm.password" type="password" autocomplete="current-password"
:placeholder="authMode === 'register' ? '设置密码(至少 8 位)' : '输入密码(至少 8 位)'"
@keyup.enter="authMode === 'register' ? doRegister() : doLogin()"
class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" /> class="w-full text-xs text-slate-700 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 outline-none focus:border-indigo-500" />
<div class="flex gap-2 pt-0.5"> <div class="pt-0.5">
<button @click="doLogin" :disabled="sync.busy" class="flex-1 py-2.5 rounded-xl text-xs font-bold bg-indigo-600 text-white active:scale-95 transition disabled:opacity-40">登录</button> <button v-if="authMode === 'login'" @click="doLogin" :disabled="sync.busy" class="w-full py-2.5 rounded-xl text-xs font-bold bg-indigo-600 text-white active:scale-95 transition disabled:opacity-40">立即登录</button>
<button v-if="sync.allowRegister" @click="doRegister" :disabled="sync.busy" class="flex-1 py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white active:scale-95 transition disabled:opacity-40">{{ sync.serverUsers === 0 ? '创建首个账号' : '注册新账号' }}</button> <button v-else @click="doRegister" :disabled="sync.busy" class="w-full py-2.5 rounded-xl text-xs font-bold bg-emerald-600 text-white active:scale-95 transition disabled:opacity-40">{{ sync.serverUsers === 0 ? '创建首个账号(管理员)' : '创建账号并登录' }}</button>
</div> </div>
</div> </div>
<p class="px-5 py-2.5 text-[10px] leading-relaxed bg-slate-50/70" <p class="px-5 py-2.5 text-[10px] leading-relaxed bg-slate-50/70"
:class="sync.serverUsers === null ? 'text-amber-700' : 'text-slate-400'"> :class="sync.serverUsers === null ? 'text-amber-700' : 'text-slate-400'">
<template v-if="sync.serverUsers === null">没探到同步服务(当前只跑静态页)。数据仍存本机,一切功能正常;要多设备共用需先启动 server/。</template> <template v-if="sync.serverUsers === null">没探到同步服务(当前只跑静态页)。数据仍存本机,一切功能正常;要多设备共用需先启动 server/。</template>
<template v-else-if="sync.serverUsers === 0">服务端还没有账号。创建首个账号后就会自动关闭注册,不会被陌生人开号。</template> <template v-else-if="sync.serverUsers === 0">服务端还没有账号。创建首个账号后就会自动关闭公开注册,保障数据安全。</template>
<template v-else-if="sync.allowRegister">服务端已开启多用户注册,填写用户名密码即可直接创建账号。</template> <template v-else-if="sync.allowRegister">服务端已开放多用户注册,填写用户名密码即可直接创建账号。</template>
<template v-else>登录后打卡数据会自动同步到服务器,多设备共用同一份。未登录也能正常记账,只存本机。</template> <template v-else>登录后打卡数据会自动同步到服务器,多设备共用同一份。未登录也能正常记账,只存本机。</template>
</p> </p>
<label class="flex items-center justify-between gap-2 px-5 py-3"> <label class="flex items-center justify-between gap-2 px-5 py-3">
@@ -822,7 +1038,7 @@
<div class="divide-y divide-slate-100"> <div class="divide-y divide-slate-100">
<div class="flex items-center justify-between px-5 py-3"> <div class="flex items-center justify-between px-5 py-3">
<span class="text-xs text-slate-600">接入开关</span> <span class="text-xs text-slate-600">接入开关</span>
<button @click="toggleMcp" :disabled="mcp.busy || !mcp.configured" class="relative w-11 h-6 rounded-full transition shrink-0 disabled:opacity-40" :class="mcp.enabled ? 'bg-emerald-500' : 'bg-slate-300'"> <button @click="toggleMcp" :disabled="mcp.busy || !mcp.configured || !sync.user || mcp.authNeeded" class="relative w-11 h-6 rounded-full transition shrink-0 disabled:opacity-40" :class="mcp.enabled ? 'bg-emerald-500' : 'bg-slate-300'">
<span class="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" :class="mcp.enabled ? 'left-[22px]' : 'left-0.5'"></span> <span class="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" :class="mcp.enabled ? 'left-[22px]' : 'left-0.5'"></span>
</button> </button>
</div> </div>
@@ -831,7 +1047,7 @@
<span class="text-[11px] font-bold text-slate-700 font-mono-num">{{ mcp.masked }}</span> <span class="text-[11px] font-bold text-slate-700 font-mono-num">{{ mcp.masked }}</span>
</div> </div>
<button v-if="mcp.configured" @click="copyMcpToken" :disabled="mcp.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use :href="mcpCopied ? '#i-check' : '#i-copy'" /></svg>{{ mcpCopied ? '已复制,去 AI 客户端粘贴' : '复制访问令牌(明文)' }}</button> <button v-if="mcp.configured" @click="copyMcpToken" :disabled="mcp.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]"><use :href="mcpCopied ? '#i-check' : '#i-copy'" /></svg>{{ mcpCopied ? '已复制,去 AI 客户端粘贴' : '复制访问令牌(明文)' }}</button>
<button @click="newMcpToken" :disabled="mcp.busy" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]" :class="{ 'animate-spin': mcp.busy }"><use href="#i-refresh-cw" /></svg>{{ mcp.configured ? '重置令牌(旧令牌立即失效)' : '生成访问令牌并开启' }}</button> <button @click="newMcpToken" :disabled="mcp.busy || !sync.user" class="w-full flex items-center gap-2 px-5 py-3.5 text-xs font-medium text-indigo-600 active:bg-slate-50 transition disabled:opacity-40"><svg class="ico text-[15px]" :class="{ 'animate-spin': mcp.busy }"><use href="#i-refresh-cw" /></svg>{{ mcp.configured ? '重置令牌(旧令牌立即失效)' : '生成访问令牌并开启' }}</button>
</div> </div>
<p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed"> <p class="px-5 py-3 text-[10px] text-slate-400 bg-slate-50/70 leading-relaxed">
端点 POST /mcp(Bearer 令牌鉴权),只读:AI 只能查询出勤、工时与薪酬,改不了任何数据。开关与令牌改动即时生效,无需重启;重置后旧客户端立即失效。 端点 POST /mcp(Bearer 令牌鉴权),只读:AI 只能查询出勤、工时与薪酬,改不了任何数据。开关与令牌改动即时生效,无需重启;重置后旧客户端立即失效。
@@ -870,8 +1086,7 @@
<!-- ===== 底部 Tab 导航 ===== --> <!-- ===== 底部 Tab 导航 ===== -->
<nav class="fixed bottom-0 left-1/2 -translate-x-1/2 w-full max-w-md bg-white/80 border-t border-slate-200/80 flex safe-bottom z-40" style="backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);"> <nav class="fixed bottom-0 left-1/2 -translate-x-1/2 w-full max-w-md bg-white/80 border-t border-slate-200/80 flex safe-bottom z-40" style="backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);">
<button v-for="t in tabs" :key="t.id" <button v-for="t in tabs" :key="t.id"
:class="t.id === 'settings' ? '' : ''" @click="tab = t.id"
@click="t.id === 'settings' ? openSettings() : (tab = t.id)"
class="flex-1 flex flex-col items-center pt-2 pb-2.5 active:scale-95 transition relative"> class="flex-1 flex flex-col items-center pt-2 pb-2.5 active:scale-95 transition relative">
<!-- 选中态指示条:品牌色,贴在顶边 --> <!-- 选中态指示条:品牌色,贴在顶边 -->
<span class="absolute top-0 h-[3px] rounded-b-full bg-indigo-600 transition-all duration-300" <span class="absolute top-0 h-[3px] rounded-b-full bg-indigo-600 transition-all duration-300"
@@ -1282,6 +1497,7 @@ createApp({
// ===== 页签 ===== // ===== 页签 =====
const tab = ref('calendar'); const tab = ref('calendar');
watch(tab, (t) => { if (t === 'settings') loadMcpStatus(); });
// icon 存 sprite 里的 symbol id,模板用 <use :href="'#' + icon"> 渲染线性图标。 // icon 存 sprite 里的 symbol id,模板用 <use :href="'#' + icon"> 渲染线性图标。
// 注意:verify_payroll.js 用例20 断言 tabs 长度=3 且 id 顺序为 calendar,summary,settings,勿改这两项。 // 注意:verify_payroll.js 用例20 断言 tabs 长度=3 且 id 顺序为 calendar,summary,settings,勿改这两项。
const tabs = [ const tabs = [
@@ -1733,6 +1949,7 @@ createApp({
serverUsers: null, allowRegister: null, apiBase: '', busy: false, hold: null serverUsers: null, allowRegister: null, apiBase: '', busy: false, hold: null
}); });
const syncPanelOpen = ref(false); const syncPanelOpen = ref(false);
const authMode = ref('login'); // 'login' | 'register'
const loginForm = ref({ username: '', password: '' }); const loginForm = ref({ username: '', password: '' });
// 首次登录合并:本机与服务器同一天内容不同时,让用户选哪份赢 // 首次登录合并:本机与服务器同一天内容不同时,让用户选哪份赢
@@ -1850,6 +2067,8 @@ createApp({
const localDays = Object.keys(records.value).length; const localDays = Object.keys(records.value).length;
const localAdj = Object.keys(monthAdjust.value).length; const localAdj = Object.keys(monthAdjust.value).length;
loadMcpStatus().catch(() => {});
// 本机干净 → 没有可丢的东西,直接拉全量 // 本机干净 → 没有可丢的东西,直接拉全量
if (localDays === 0 && localAdj === 0) { if (localDays === 0 && localAdj === 0) {
await window.WHTSync.pullAll(); await window.WHTSync.pullAll();
@@ -1943,6 +2162,7 @@ createApp({
const doLogout = () => withBusy(async () => { const doLogout = () => withBusy(async () => {
if (sync.pending > 0 && !confirm(`还有 ${sync.pending} 项未上传,确定退出?本地数据不会删除。`)) return; if (sync.pending > 0 && !confirm(`还有 ${sync.pending} 项未上传,确定退出?本地数据不会删除。`)) return;
await window.WHTSync.logout({ keepLocal: true }); await window.WHTSync.logout({ keepLocal: true });
resetMcpState(true);
showToast('已退出登录,本地数据保留'); showToast('已退出登录,本地数据保留');
}); });
@@ -1972,9 +2192,19 @@ createApp({
// 开关与令牌存服务器 DB,改动即时生效;令牌明文只在生成/复制响应里出现,页面不留存。 // 开关与令牌存服务器 DB,改动即时生效;令牌明文只在生成/复制响应里出现,页面不留存。
const mcp = reactive({ configured: false, enabled: false, masked: '', busy: false, loaded: false, authNeeded: false }); const mcp = reactive({ configured: false, enabled: false, masked: '', busy: false, loaded: false, authNeeded: false });
const mcpCopied = ref(false); const mcpCopied = ref(false);
const resetMcpState = (authNeeded = true) => {
mcp.configured = false;
mcp.enabled = false;
mcp.masked = '';
mcp.authNeeded = !!authNeeded;
mcp.loaded = true;
mcp.busy = false;
};
const mcpBadge = computed(() => { const mcpBadge = computed(() => {
if (!mcp.loaded) return { text: '…', cls: 'bg-slate-100 text-slate-400' }; if (!mcp.loaded) return { text: '…', cls: 'bg-slate-100 text-slate-400' };
if (mcp.authNeeded) return { text: '本设备请先登录', cls: 'bg-amber-50 text-amber-600' }; if (mcp.authNeeded || !sync.user) return { text: '本设备请先登录', cls: 'bg-amber-50 text-amber-600' };
if (!mcp.configured) return { text: '未配置', cls: 'bg-slate-100 text-slate-400' }; if (!mcp.configured) return { text: '未配置', cls: 'bg-slate-100 text-slate-400' };
return mcp.enabled return mcp.enabled
? { text: '已开启', cls: 'bg-emerald-50 text-emerald-600' } ? { text: '已开启', cls: 'bg-emerald-50 text-emerald-600' }
@@ -1990,15 +2220,24 @@ createApp({
}; };
const loadMcpStatus = async () => { const loadMcpStatus = async () => {
if (!sync.user || !window.WHTSync || !window.WHTSync.state.token) {
resetMcpState(true);
return;
}
try { try {
const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/status', { headers: mcpHeaders() }); const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/status', { headers: mcpHeaders() });
const d = await res.json(); const d = await res.json();
mcp.authNeeded = res.status === 401 || res.status === 403; if (res.status === 401 || res.status === 403) {
resetMcpState(true);
return;
}
if (d && d.ok) { if (d && d.ok) {
mcp.configured = !!d.configured; mcp.configured = !!d.configured;
mcp.enabled = !!d.enabled; mcp.enabled = !!d.enabled;
mcp.masked = d.masked || ''; mcp.masked = d.masked || '';
if (d.configured) mcp.authNeeded = false; mcp.authNeeded = false;
} else {
resetMcpState(false);
} }
} catch (e) { /* 静态模式/离线:保持未知态,不干扰记账主功能 */ } } catch (e) { /* 静态模式/离线:保持未知态,不干扰记账主功能 */ }
mcp.loaded = true; mcp.loaded = true;
@@ -2006,6 +2245,10 @@ createApp({
const toggleMcp = async () => { const toggleMcp = async () => {
if (mcp.busy) return; if (mcp.busy) return;
if (!sync.user || mcp.authNeeded) {
showToast('请先登录账号后再设置 MCP');
return;
}
mcp.busy = true; mcp.busy = true;
try { try {
const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/enabled', { const res = await fetch(window.WHTSync.getApiBase() + '/api/mcp/enabled', {
@@ -2126,6 +2369,7 @@ createApp({
openAdjust, closeAdjust, saveAdjustDraft, finalizeAdjust, unfinalizeAdjust, openAdjust, closeAdjust, saveAdjustDraft, finalizeAdjust, unfinalizeAdjust,
changeMonth, goToday, openDayDrawer, selectShift, changeMonth, goToday, openDayDrawer, selectShift,
openSettings, closeSettings, openSettings, closeSettings,
authMode,
sync, syncPanelOpen, loginForm, syncStatusText, syncStatusClass, syncTimeText, sync, syncPanelOpen, loginForm, syncStatusText, syncStatusClass, syncTimeText,
mergeOpen, mergeInfo, mergeRows, resolveMerge, deferMerge, reopenMerge, mergeOpen, mergeInfo, mergeRows, resolveMerge, deferMerge, reopenMerge,
doLogin, doRegister, doLogout, doSyncNow, doPullAll, doPushAll, saveApiBase, doLogin, doRegister, doLogout, doSyncNow, doPullAll, doPushAll, saveApiBase,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "work-hours-tracker", "name": "work-hours-tracker",
"version": "1.0.0", "version": "1.0.3",
"private": true, "private": true,
"description": "工时记账:单文件前端 + 薪酬纯模块(shared/payroll.js)+ 平台无关服务端(server/)", "description": "工时记账:单文件前端 + 薪酬纯模块(shared/payroll.js)+ 平台无关服务端(server/)",
"scripts": { "scripts": {
+16 -10
View File
@@ -287,26 +287,32 @@
} }
// ===== 格式化 ===== // ===== 格式化 =====
function fmt(n) { return (Number(n) || 0).toFixed(1); } // 消除浮点二进制抖动,同时保留原始数据(不进行有损进位截断)
function money(n) { return '¥' + (Number(n) || 0).toFixed(1); } function rawAmount(n) {
function signed(n) {
var v = Number(n) || 0; var v = Number(n) || 0;
return (v >= 0 ? '+¥' : '-¥') + Math.abs(v).toFixed(1); return Math.round((v + Number.EPSILON) * 100) / 100;
}
function fmt(n) { return (Number(n) || 0).toFixed(1); }
function money(n) { return '¥' + rawAmount(n); }
function signed(n) {
var v = rawAmount(n);
return (v >= 0 ? '+¥' : '-¥') + Math.abs(v);
} }
/** /**
* 金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。 * 金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。
* 必须从 toFixed(1) 的结果上切,不能用 Math.floor 另算, * 保留原始数据的小数部分,不进行有损四舍五入。
* 否则 99.96 会被 money() 进为 100.0、而拆分出 99.0,两处数字对不上。
*/ */
function moneyParts(n) { function moneyParts(n) {
var v = Number(n) || 0; var v = rawAmount(n);
var s = Math.abs(v).toFixed(1); var s = String(Math.abs(v));
var dot = s.indexOf('.'); var dot = s.indexOf('.');
var intPart = dot !== -1 ? s.slice(0, dot) : s;
var fracPart = dot !== -1 ? s.slice(dot) : '';
return { return {
sign: v < 0 ? '-' : '', sign: v < 0 ? '-' : '',
int: s.slice(0, dot).replace(/\B(?=(\d{3})+(?!\d))/g, ','), // 千分位 int: intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','), // 千分位
frac: s.slice(dot) frac: fracPart
}; };
} }
+9 -1
View File
@@ -87,10 +87,18 @@
emit(); emit();
} }
const DEFAULT_REMOTE_BASE = 'https://daka.kory.kdns.fr';
function apiBase() { function apiBase() {
const saved = readLS(LS.base, ''); const saved = readLS(LS.base, '');
if (saved) return String(saved).replace(/\/+$/, ''); if (saved) return String(saved).replace(/\/+$/, '');
return location.origin; // 默认同源:VPS 与 CF Workers 都是页面与 API 同一个域 // 运行在 Android 本地 assets (file://) 或非 http(s) 环境时,回退到默认预设服务器
if (typeof location !== 'undefined' && location.origin && /^https?:\/\//.test(location.origin)) {
// 如果是在本机开发测试(127.0.0.1/localhost)或部署的域名,默认同源;
// 如果没有指定 saved,则仍可回退到默认远程服务器(如果 origin 不是服务域名)
return location.origin;
}
return DEFAULT_REMOTE_BASE;
} }
async function api(path, { method = 'GET', body, auth = true, timeoutMs = 15000 } = {}) { async function api(path, { method = 'GET', body, auth = true, timeoutMs = 15000 } = {}) {
+30 -30
View File
@@ -110,7 +110,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 4, "recordCount": 4,
"boardInt": "702", "boardInt": "702",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -226,7 +226,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 2, "recordCount": 2,
"boardInt": "468", "boardInt": "468",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -342,7 +342,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 1, "recordCount": 1,
"boardInt": "195", "boardInt": "195",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -458,7 +458,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 27, "recordCount": 27,
"boardInt": "4,428", "boardInt": "4,428",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -574,7 +574,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 26, "recordCount": 26,
"boardInt": "4,056", "boardInt": "4,056",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -704,7 +704,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 3, "recordCount": 3,
"boardInt": "468", "boardInt": "468",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -834,7 +834,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 3, "recordCount": 3,
"boardInt": "408", "boardInt": "408",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -950,7 +950,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 1, "recordCount": 1,
"boardInt": "156", "boardInt": "156",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -1066,7 +1066,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 1, "recordCount": 1,
"boardInt": "176", "boardInt": "176",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -1182,7 +1182,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 6, "recordCount": 6,
"boardInt": "312", "boardInt": "312",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -1298,7 +1298,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 6, "recordCount": 6,
"boardInt": "312", "boardInt": "312",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -1414,7 +1414,7 @@
"isCurrentMonth": false, "isCurrentMonth": false,
"recordCount": 3, "recordCount": 3,
"boardInt": "156", "boardInt": "156",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 31, "daysInMonth": 31,
@@ -1530,7 +1530,7 @@
"isCurrentMonth": false, "isCurrentMonth": false,
"recordCount": 3, "recordCount": 3,
"boardInt": "156", "boardInt": "156",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 31, "daysInMonth": 31,
@@ -1654,7 +1654,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 1, "recordCount": 1,
"boardInt": "117", "boardInt": "117",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -1778,7 +1778,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 1, "recordCount": 1,
"boardInt": "283", "boardInt": "283",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "-", "boardSign": "-",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -1894,7 +1894,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 4, "recordCount": 4,
"boardInt": "156", "boardInt": "156",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -2010,7 +2010,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 1, "recordCount": 1,
"boardInt": "156", "boardInt": "156",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -2126,7 +2126,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 3, "recordCount": 3,
"boardInt": "156", "boardInt": "156",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -2242,7 +2242,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 20, "recordCount": 20,
"boardInt": "6,400", "boardInt": "6,400",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -2358,7 +2358,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 1, "recordCount": 1,
"boardInt": "156", "boardInt": "156",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -2494,7 +2494,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 4, "recordCount": 4,
"boardInt": "624", "boardInt": "624",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -2610,7 +2610,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 0, "recordCount": 0,
"boardInt": "0", "boardInt": "0",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -2726,7 +2726,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 3, "recordCount": 3,
"boardInt": "516", "boardInt": "516",
"boardFrac": ".8", "boardFrac": ".75",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -2842,7 +2842,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 3, "recordCount": 3,
"boardInt": "429", "boardInt": "429",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -2958,7 +2958,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 3, "recordCount": 3,
"boardInt": "439", "boardInt": "439",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -3074,7 +3074,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 27, "recordCount": 27,
"boardInt": "4,428", "boardInt": "4,428",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -3190,7 +3190,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 27, "recordCount": 27,
"boardInt": "4,478", "boardInt": "4,478",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
@@ -3306,7 +3306,7 @@
"isCurrentMonth": false, "isCurrentMonth": false,
"recordCount": 2, "recordCount": 2,
"boardInt": "351", "boardInt": "351",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 31, "daysInMonth": 31,
@@ -3422,7 +3422,7 @@
"isCurrentMonth": false, "isCurrentMonth": false,
"recordCount": 2, "recordCount": 2,
"boardInt": "351", "boardInt": "351",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 31, "daysInMonth": 31,
@@ -3538,7 +3538,7 @@
"isCurrentMonth": true, "isCurrentMonth": true,
"recordCount": 0, "recordCount": 0,
"boardInt": "0", "boardInt": "0",
"boardFrac": ".0", "boardFrac": "",
"boardSign": "", "boardSign": "",
"hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12", "hourOptions": "6,6.5,7,7.5,8,8.5,9,9.5,10,10.5,11,11.5,12",
"daysInMonth": 30, "daysInMonth": 30,
+13 -11
View File
@@ -425,20 +425,22 @@ test('isCurrentMonth / isSameDay 只认传进来的 now', () => {
// ───────────────────────── 格式化 ───────────────────────── // ───────────────────────── 格式化 ─────────────────────────
test('金额格式化保留 1 位,null/undefined 当 0', () => { test('金额格式化保留原始数据(分位不截断,不进位),null/undefined 当 0', () => {
assert.equal(P.fmt(undefined), '0.0'); assert.equal(P.fmt(undefined), '0.0');
assert.equal(P.money(null), '¥0.0'); assert.equal(P.money(null), '¥0');
assert.equal(P.money(1234.56), '¥1234.6'); assert.equal(P.money(1234.56), '¥1234.56');
assert.equal(P.signed(0), '+¥0.0'); assert.equal(P.money(2018.25), '¥2018.25');
assert.equal(P.signed(-5), '-¥5.0'); assert.equal(P.signed(0), '+¥0');
assert.equal(P.signed(30), '+¥30.0'); assert.equal(P.signed(-5), '-¥5');
assert.equal(P.signed(-50.25), '-¥50.25');
assert.equal(P.signed(30), '+¥30');
}); });
test('moneyParts 从 toFixed(1) 的结果上切,不另算整数部', () => { test('moneyParts 保留原始数据小数位,千分位与小数部拆分对齐', () => {
// 99.96 若用 Math.floor 另算会得到 99,与 money() 的 ¥100.0 打架 assert.deepEqual(P.moneyParts(99.96), { sign: '', int: '99', frac: '.96' });
assert.deepEqual(P.moneyParts(99.96), { sign: '', int: '100', frac: '.0' }); assert.deepEqual(P.moneyParts(2018.25), { sign: '', int: '2,018', frac: '.25' });
assert.deepEqual(P.moneyParts(-1234.56), { sign: '-', int: '1,234', frac: '.6' }); assert.deepEqual(P.moneyParts(-1234.56), { sign: '-', int: '1,234', frac: '.56' });
assert.deepEqual(P.moneyParts(0), { sign: '', int: '0', frac: '.0' }); assert.deepEqual(P.moneyParts(0), { sign: '', int: '0', frac: '' });
assert.deepEqual(P.moneyParts(1234567.8), { sign: '', int: '1,234,567', frac: '.8' }); assert.deepEqual(P.moneyParts(1234567.8), { sign: '', int: '1,234,567', frac: '.8' });
}); });
+1
View File
@@ -47,6 +47,7 @@ function load(records, year, month, settings, adjust) {
navigator: {}, navigator: {},
document: { document: {
createElement: () => ({ style: {}, select() {}, click() {} }), createElement: () => ({ style: {}, select() {}, click() {} }),
getElementById: () => null,
body: { appendChild() {}, removeChild() {} }, body: { appendChild() {}, removeChild() {} },
addEventListener() {} addEventListener() {}
}, },