添加收集箱与快捷指令保存功能

This commit is contained in:
2026-07-15 22:19:36 +08:00
parent 423132699e
commit bbd4f0e266
4 changed files with 386 additions and 1 deletions
+216
View File
@@ -0,0 +1,216 @@
//
// ClipInboxViewController.swift
// Note
//
// Created by Kimi Code CLI on 2026/7/15.
//
import UIKit
class ClipInboxViewController: UIViewController {
private var tableView: UITableView!
private var emptyLabel: UILabel!
private var clips: [Clip] = []
override func viewDidLoad() {
super.viewDidLoad()
title = "收集箱"
navigationItem.largeTitleDisplayMode = .never
view.backgroundColor = .systemGroupedBackground
setupNavigationBar()
setupTableView()
setupEmptyLabel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadClips()
}
private func setupNavigationBar() {
let clearButton = UIBarButtonItem(
title: "清空",
style: .plain,
target: self,
action: #selector(didTapClearButton)
)
clearButton.tintColor = .systemRed
navigationItem.rightBarButtonItem = clearButton
}
private func setupTableView() {
tableView = UITableView(frame: .zero, style: .insetGrouped)
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.delegate = self
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "clipCell")
tableView.backgroundColor = .systemGroupedBackground
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func setupEmptyLabel() {
emptyLabel = UILabel()
emptyLabel.translatesAutoresizingMaskIntoConstraints = false
emptyLabel.text = "还没有保存的文案\n复制内容后运行快捷指令即可收藏"
emptyLabel.numberOfLines = 0
emptyLabel.textAlignment = .center
emptyLabel.textColor = .secondaryLabel
emptyLabel.font = UIFont.preferredFont(forTextStyle: .body)
emptyLabel.isHidden = true
view.addSubview(emptyLabel)
NSLayoutConstraint.activate([
emptyLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
emptyLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
emptyLabel.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
emptyLabel.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor)
])
}
private func loadClips() {
Task {
do {
clips = try await ClipStore.shared.loadAll()
await MainActor.run {
tableView.reloadData()
updateEmptyState()
}
} catch {
await MainActor.run {
showError(message: "加载失败: \(error.localizedDescription)")
}
}
}
}
private func updateEmptyState() {
let isEmpty = clips.isEmpty
emptyLabel.isHidden = !isEmpty
tableView.isHidden = isEmpty
navigationItem.rightBarButtonItem?.isEnabled = !isEmpty
}
@objc private func didTapClearButton() {
let alert = UIAlertController(
title: "清空收集箱",
message: "确定要删除所有保存的文案吗?此操作不可撤销。",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "清空", style: .destructive) { [weak self] _ in
self?.clearAllClips()
})
present(alert, animated: true)
}
private func clearAllClips() {
Task {
do {
try await ClipStore.shared.clearAll()
clips = []
await MainActor.run {
tableView.reloadData()
updateEmptyState()
}
} catch {
await MainActor.run {
showError(message: "清空失败: \(error.localizedDescription)")
}
}
}
}
private func deleteClip(at indexPath: IndexPath) {
let clip = clips[indexPath.row]
Task {
do {
try await ClipStore.shared.delete(id: clip.id)
clips.remove(at: indexPath.row)
await MainActor.run {
tableView.deleteRows(at: [indexPath], with: .automatic)
updateEmptyState()
}
} catch {
await MainActor.run {
showError(message: "删除失败: \(error.localizedDescription)")
}
}
}
}
private func openClip(_ clip: Clip) {
let viewController = FormatNoteViewController()
viewController.initialText = clip.text
navigationController?.pushViewController(viewController, animated: true)
}
private func showError(message: String) {
let alert = UIAlertController(title: "提示", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "确定", style: .default))
present(alert, animated: true)
}
}
extension ClipInboxViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return clips.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "clipCell", for: indexPath)
let clip = clips[indexPath.row]
var config = cell.defaultContentConfiguration()
config.text = previewText(for: clip.text)
config.secondaryText = formattedDate(clip.createdAt)
config.secondaryTextProperties.color = .secondaryLabel
cell.contentConfiguration = config
cell.accessoryType = .disclosureIndicator
return cell
}
private func previewText(for text: String) -> String {
let lines = text.components(separatedBy: .newlines)
let firstTwoLines = Array(lines.prefix(2))
let preview = firstTwoLines.joined(separator: " ").trimmingCharacters(in: .whitespaces)
if preview.isEmpty {
return "(无内容)"
}
return preview
}
private func formattedDate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter.localizedString(for: date, relativeTo: Date())
}
}
extension ClipInboxViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let clip = clips[indexPath.row]
openClip(clip)
}
func tableView(_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let deleteAction = UIContextualAction(style: .destructive, title: "删除") { [weak self] _, _, completion in
self?.deleteClip(at: indexPath)
completion(true)
}
return UISwipeActionsConfiguration(actions: [deleteAction])
}
}
+92
View File
@@ -0,0 +1,92 @@
//
// ClipStore.swift
// Note
//
// Created by Kimi Code CLI on 2026/7/15.
//
import Foundation
struct Clip: Codable, Identifiable, Equatable, Sendable {
let id: UUID
var text: String
let createdAt: Date
var source: String?
nonisolated init(id: UUID = UUID(), text: String, createdAt: Date = Date(), source: String? = nil) {
self.id = id
self.text = text
self.createdAt = createdAt
self.source = source
}
}
actor ClipStore {
static let shared = ClipStore()
private let appGroupIdentifier = "group.com.fishestlife.note"
private let fileName = "clips.json"
private let subdirectory = "SharedFiles/Clips"
private var cachedClips: [Clip]?
private var fileURL: URL {
let fileManager = FileManager.default
guard let containerURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else {
fatalError("无法访问 App Group 容器: \(appGroupIdentifier)")
}
let directoryURL = containerURL.appendingPathComponent(subdirectory, isDirectory: true)
try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
return directoryURL.appendingPathComponent(fileName)
}
private init() {}
func save(text: String, source: String? = nil) async throws {
var clips = try await loadAll()
let clip = Clip(text: text, source: source)
clips.insert(clip, at: 0)
try await persist(clips)
}
func loadAll() async throws -> [Clip] {
if let cached = cachedClips {
return cached
}
let url = fileURL
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: url.path) else {
cachedClips = []
return []
}
let data = try Data(contentsOf: url)
let clips = try JSONDecoder().decode([Clip].self, from: data)
cachedClips = clips
return clips
}
func delete(id: UUID) async throws {
var clips = try await loadAll()
clips.removeAll { $0.id == id }
try await persist(clips)
}
func clearAll() async throws {
try await persist([])
}
func count() async throws -> Int {
let clips = try await loadAll()
return clips.count
}
private func persist(_ clips: [Clip]) async throws {
let url = fileURL
let data = try JSONEncoder().encode(clips)
try data.write(to: url, options: .atomic)
cachedClips = clips
}
}
+37
View File
@@ -0,0 +1,37 @@
//
// SaveClipIntent.swift
// Note
//
// Created by Kimi Code CLI on 2026/7/15.
//
import AppIntents
struct SaveClipIntent: AppIntent {
static var title: LocalizedStringResource = "保存剪贴板到 Note"
static var description: IntentDescription? = "将一段文本保存到 Note App 的收集箱,稍后统一处理。"
@Parameter(title: "文案", description: "要保存的文本内容", requestValueDialog: "请提供要保存的文案")
var text: String
@Parameter(title: "来源", description: "文案来源,例如应用名称", default: "快捷指令")
var source: String?
init() {}
init(text: String, source: String? = nil) {
self.text = text
self.source = source
}
func perform() async throws -> some IntentResult {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return .result(dialog: "文案为空,未保存。")
}
try await ClipStore.shared.save(text: trimmed, source: source)
return .result(dialog: "已保存到收集箱。")
}
}
+41 -1
View File
@@ -17,8 +17,9 @@ class ViewController: UIViewController {
let title: String let title: String
} }
private let menuItems: [[MenuItem]] = [ private var menuItems: [[MenuItem]] = [
[ [
MenuItem(icon: "tray.full", iconColor: .systemIndigo, title: "收集箱"),
MenuItem(icon: "note.text", iconColor: .systemYellow, title: "打开备忘录"), MenuItem(icon: "note.text", iconColor: .systemYellow, title: "打开备忘录"),
MenuItem(icon: "doc.text", iconColor: .systemBlue, title: "格式化日志"), MenuItem(icon: "doc.text", iconColor: .systemBlue, title: "格式化日志"),
MenuItem(icon: "text.alignleft", iconColor: .systemGreen, title: "格式化笔记"), MenuItem(icon: "text.alignleft", iconColor: .systemGreen, title: "格式化笔记"),
@@ -31,6 +32,11 @@ class ViewController: UIViewController {
setupUI() setupUI()
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
updateInboxBadge()
}
private func setupUI() { private func setupUI() {
title = "首页" title = "首页"
navigationController?.navigationBar.prefersLargeTitles = true navigationController?.navigationBar.prefersLargeTitles = true
@@ -87,6 +93,9 @@ extension ViewController: UITableViewDelegate {
let item = menuItems[indexPath.section][indexPath.row] let item = menuItems[indexPath.section][indexPath.row]
switch item.title { switch item.title {
case "收集箱":
let viewController = ClipInboxViewController()
navigationController?.pushViewController(viewController, animated: true)
case "打开备忘录": case "打开备忘录":
openSystemNotesApp() openSystemNotesApp()
case "格式化日志": case "格式化日志":
@@ -102,6 +111,37 @@ extension ViewController: UITableViewDelegate {
} }
} }
private func updateInboxBadge() {
Task {
guard let count = try? await ClipStore.shared.count(), count > 0 else {
await MainActor.run {
self.updateInboxTitle("收集箱")
}
return
}
await MainActor.run {
self.updateInboxTitle("收集箱(\(count)")
}
}
}
private func updateInboxTitle(_ title: String) {
for (sectionIndex, section) in menuItems.enumerated() {
for (rowIndex, item) in section.enumerated() {
if item.title.hasPrefix("收集箱") {
menuItems[sectionIndex][rowIndex] = MenuItem(
icon: item.icon,
iconColor: item.iconColor,
title: title
)
let indexPath = IndexPath(row: rowIndex, section: sectionIndex)
tableView.reloadRows(at: [indexPath], with: .none)
return
}
}
}
}
private func openSystemNotesApp() { private func openSystemNotesApp() {
openURLIfPossible("mobilenotes://") openURLIfPossible("mobilenotes://")
} }