```
refactor: 重命名项目名称从"体己"到"康康" 将整个项目的目录结构从"体己"重命名为"康康",包括所有源代码文件、 资源文件、测试文件以及Xcode项目配置文件。此更改涉及项目中所有的 文件路径和应用入口点(App/TijiApp.swift → App/KangkangApp.swift)。 ```
This commit is contained in:
99
康康/AI/AIRuntime.swift
Normal file
99
康康/AI/AIRuntime.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
enum AIRuntimeError: Error, LocalizedError {
|
||||
case notReady
|
||||
case modelLoadFailed(String)
|
||||
case inferenceFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notReady: return "AI 模型尚未准备好"
|
||||
case .modelLoadFailed(let m): return "模型加载失败:\(m)"
|
||||
case .inferenceFailed(let m): return "推理失败:\(m)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor AIRuntime {
|
||||
static let shared = AIRuntime()
|
||||
|
||||
enum Status: Sendable, Equatable {
|
||||
case notReady
|
||||
case loading
|
||||
case ready
|
||||
case error(String)
|
||||
}
|
||||
|
||||
private(set) var status: Status = .notReady
|
||||
private(set) var lastDecodeRate: Double = 0
|
||||
|
||||
private var llmSession: LLMSession?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// 加载模型。首次调用会真正加载,后续幂等。
|
||||
func prepare() async throws {
|
||||
switch status {
|
||||
case .ready:
|
||||
return
|
||||
case .loading:
|
||||
// 已有其他调用方在加载;本次 prepare 直接返回,
|
||||
// 调用方需稍后 await prepare() 再判 status,或自行轮询 / 显示加载 UI。
|
||||
// W3 引入 prepare 队列时优化。
|
||||
return
|
||||
case .error, .notReady:
|
||||
break
|
||||
}
|
||||
|
||||
guard ModelStore.shared.isReady(.llm) else {
|
||||
status = .error("LLM 模型未就绪")
|
||||
throw AIRuntimeError.notReady
|
||||
}
|
||||
|
||||
status = .loading
|
||||
do {
|
||||
let session = try await LLMSession.load(
|
||||
folderURL: ModelStore.shared.localURL(for: .llm)
|
||||
)
|
||||
self.llmSession = session
|
||||
status = .ready
|
||||
} catch {
|
||||
status = .error("\(error)")
|
||||
throw AIRuntimeError.modelLoadFailed("\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式生成。调用前应先 await prepare()。
|
||||
/// 注意:返回流是同步创建的,但跨 actor 调用 LLMSession 需要 await。
|
||||
func generate(prompt: String, maxTokens: Int = 256) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
// 在 actor 隔离上下文中捕获快照,Task 内不再访问 self.status / self.llmSession
|
||||
let snapshotStatus = status
|
||||
let snapshotSession = llmSession
|
||||
|
||||
return AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
guard snapshotStatus == .ready, let session = snapshotSession else {
|
||||
continuation.finish(throwing: AIRuntimeError.notReady)
|
||||
return
|
||||
}
|
||||
do {
|
||||
// session.generate 跨 actor 边界,需要 await
|
||||
let stream = await session.generate(prompt: prompt, maxTokens: maxTokens)
|
||||
for try await chunk in stream {
|
||||
// Task 闭包在 generate() 内启动,继承 AIRuntime 的 actor 隔离;
|
||||
// 调用同 actor 的 recordRate 不需要 await
|
||||
self.recordRate(chunk.decodeRate)
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: AIRuntimeError.inferenceFailed("\(error)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recordRate(_ rate: Double) {
|
||||
if rate > 0 { lastDecodeRate = rate }
|
||||
}
|
||||
}
|
||||
87
康康/AI/LLMSession.swift
Normal file
87
康康/AI/LLMSession.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import MLX
|
||||
import MLXLLM
|
||||
import MLXLMCommon
|
||||
|
||||
/// 封装 MLX 语言模型的流式生成,actor 保证单线程访问。
|
||||
/// 基于 mlx-swift-examples 2.29.1(commit 9bff95ca)的 API。
|
||||
actor LLMSession {
|
||||
let container: ModelContainer
|
||||
|
||||
init(container: ModelContainer) {
|
||||
self.container = container
|
||||
}
|
||||
|
||||
/// 从本地目录加载模型(包含 config.json + weights + tokenizer)。
|
||||
static func load(folderURL: URL) async throws -> LLMSession {
|
||||
#if targetEnvironment(simulator)
|
||||
// MLX 的 iOS Simulator GPU stream 初始化会在部分 Metal backend 路径中 abort。
|
||||
// 模拟器只用于功能调试,强制走 CPU;真机仍保留默认 GPU/ANE 相关路径。
|
||||
Device.setDefault(device: .cpu)
|
||||
#endif
|
||||
|
||||
let configuration = ModelConfiguration(directory: folderURL)
|
||||
let container = try await LLMModelFactory.shared.loadContainer(
|
||||
configuration: configuration
|
||||
)
|
||||
return LLMSession(container: container)
|
||||
}
|
||||
|
||||
/// 流式生成。返回的 AsyncThrowingStream 被取消时,内部 Task 也会取消。
|
||||
/// - Parameters:
|
||||
/// - prompt: 原始 prompt 文本(经 processor 转 LMInput)
|
||||
/// - maxTokens: 最大 token 数,由 GenerateParameters 控制
|
||||
func generate(prompt: String, maxTokens: Int) -> AsyncThrowingStream<TokenChunk, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
let parameters = GenerateParameters(
|
||||
maxTokens: maxTokens,
|
||||
temperature: Float(0.6),
|
||||
topP: Float(0.9)
|
||||
)
|
||||
|
||||
try await container.perform { (context: ModelContext) in
|
||||
let userInput = UserInput(prompt: prompt)
|
||||
let lmInput = try await context.processor.prepare(input: userInput)
|
||||
|
||||
let start = Date()
|
||||
var produced = 0
|
||||
|
||||
for await event in try MLXLMCommon.generate(
|
||||
input: lmInput,
|
||||
parameters: parameters,
|
||||
context: context
|
||||
) {
|
||||
if Task.isCancelled { break }
|
||||
|
||||
switch event {
|
||||
case .chunk(let text):
|
||||
produced += 1
|
||||
let elapsed = Date().timeIntervalSince(start)
|
||||
let rate = elapsed > 0 ? Double(produced) / elapsed : 0
|
||||
continuation.yield(TokenChunk(text: text, decodeRate: rate))
|
||||
|
||||
case .info:
|
||||
// 生成完成统计,是流的最后一个事件
|
||||
break
|
||||
|
||||
case .toolCall:
|
||||
// 纯文本生成不会触发,switch 穷举
|
||||
break
|
||||
}
|
||||
}
|
||||
// 注:研究笔记里曾建议尾部 MLX.GPU.synchronize() 以确保
|
||||
// GPU 操作全部完成。但 AsyncStream 已经 yield 真实解码后的
|
||||
// 文字,GPU 是否完全空闲不影响数据正确性。去掉此调用同时省
|
||||
// 一份 transitive import MLX 的依赖,简化 SPM 链接。
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
87
康康/AI/ModelStore.swift
Normal file
87
康康/AI/ModelStore.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
enum ModelKind: String, CaseIterable {
|
||||
/// 与 HuggingFace mlx-community 仓库名一一对应,也是沙盒 Models/ 下的子目录名。
|
||||
case llm = "Qwen3-1.7B-4bit"
|
||||
case vl = "Qwen2.5-VL-3B-Instruct-4bit"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .llm: return "Qwen3-1.7B"
|
||||
case .vl: return "Qwen2.5-VL-3B"
|
||||
}
|
||||
}
|
||||
|
||||
/// HuggingFace 仓库 ID(org/name),用于下载
|
||||
var huggingFaceRepo: String { "mlx-community/\(rawValue)" }
|
||||
|
||||
/// 用于判定该模型是否已就绪的最小标志文件
|
||||
var sentinelFilename: String { "config.json" }
|
||||
}
|
||||
|
||||
/// `@unchecked Sendable`:rootURL 是 let,方法只读 filesystem(线程安全),
|
||||
/// 可被任意 actor / Task 跨边界访问。
|
||||
/// `nonisolated(unsafe) shared`:项目开启 `-default-isolation=MainActor` 后
|
||||
/// static 默认 MainActor 隔离,跨 actor 访问需要 await。这里手动 opt-out。
|
||||
final class ModelStore: @unchecked Sendable {
|
||||
nonisolated(unsafe) static let shared: ModelStore = {
|
||||
do {
|
||||
let appSupport = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let root = appSupport.appendingPathComponent("Models", isDirectory: true)
|
||||
return try ModelStore(rootURL: root)
|
||||
} catch {
|
||||
fatalError("ModelStore.shared init failed: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
let rootURL: URL
|
||||
|
||||
init(rootURL: URL) throws {
|
||||
self.rootURL = rootURL
|
||||
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func localURL(for kind: ModelKind) -> URL {
|
||||
rootURL.appendingPathComponent(kind.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
func isReady(_ kind: ModelKind) -> Bool {
|
||||
let sentinel = localURL(for: kind).appendingPathComponent(kind.sentinelFilename)
|
||||
return FileManager.default.fileExists(atPath: sentinel.path)
|
||||
}
|
||||
|
||||
func totalBytes(for kind: ModelKind) -> Int {
|
||||
let folder = localURL(for: kind)
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: folder,
|
||||
includingPropertiesForKeys: [.fileSizeKey]
|
||||
) else { return 0 }
|
||||
var sum = 0
|
||||
for case let url as URL in enumerator {
|
||||
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||
sum += size
|
||||
}
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
/// Demo 现场旁路:从 Bundle 拷贝预装模型(W6 才真正使用,本周占位)
|
||||
func seedFromBundle(_ kind: ModelKind) throws {
|
||||
guard let bundleURL = Bundle.main.url(forResource: kind.rawValue, withExtension: nil) else {
|
||||
#if DEBUG
|
||||
assertionFailure("Bundle 缺少 \(kind.rawValue),检查资源是否加入 target")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
let target = localURL(for: kind)
|
||||
if FileManager.default.fileExists(atPath: target.path) {
|
||||
try FileManager.default.removeItem(at: target)
|
||||
}
|
||||
try FileManager.default.copyItem(at: bundleURL, to: target)
|
||||
}
|
||||
}
|
||||
6
康康/AI/TokenChunk.swift
Normal file
6
康康/AI/TokenChunk.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct TokenChunk: Sendable {
|
||||
let text: String
|
||||
let decodeRate: Double // tokens / second, 估算值
|
||||
}
|
||||
28
康康/App/KangkangApp.swift
Normal file
28
康康/App/KangkangApp.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct TijiApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
Indicator.self,
|
||||
Report.self,
|
||||
DiaryEntry.self,
|
||||
Asset.self,
|
||||
ChatTurn.self,
|
||||
])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
}
|
||||
11
康康/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
11
康康/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
85
康康/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
85
康康/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
康康/Assets.xcassets/Contents.json
Normal file
6
康康/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
203
康康/Debug/DebugAIRunner.swift
Normal file
203
康康/Debug/DebugAIRunner.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// DEBUG 自检:加载 LLM 并跑一段 prompt,流式显示 token + 速率。
|
||||
/// 同时显示沙盒 Application Support 路径,方便把模型拷进去。
|
||||
struct DebugAIRunner: View {
|
||||
@State private var output: String = ""
|
||||
@State private var status: String = "未开始"
|
||||
@State private var rate: Double = 0
|
||||
@State private var running = false
|
||||
@State private var modelReady: Bool = false
|
||||
@State private var importingModel = false
|
||||
@State private var importError: String?
|
||||
|
||||
private var appSupportPath: String {
|
||||
(try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: false
|
||||
).path) ?? "(无法获取)"
|
||||
}
|
||||
|
||||
private var modelExpectedPath: String {
|
||||
appSupportPath + "/Models/Qwen3-1.7B-4bit"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("DEBUG · AI 自检")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
|
||||
// 沙盒路径与模型状态卡
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("模型预期路径")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(modelExpectedPath)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(3)
|
||||
HStack(spacing: 8) {
|
||||
Button("复制路径") {
|
||||
UIPasteboard.general.string = modelExpectedPath
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
Button(importingModel ? "导入中..." : "导入模型") {
|
||||
importingModel = true
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(importingModel)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(modelReady ? "✓ 模型就绪" : "⚠ 模型未就绪")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(modelReady ? Tj.Palette.leaf : Tj.Palette.brick)
|
||||
}
|
||||
if let importError {
|
||||
Text(importError)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.black.opacity(0.03))
|
||||
)
|
||||
|
||||
// 推理状态
|
||||
HStack {
|
||||
Text("状态:\(status)")
|
||||
Spacer()
|
||||
Text(String(format: "%.1f tok/s", rate))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.monospaced()
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
|
||||
Button(running ? "推理中..." : "跑一段 prompt") {
|
||||
Task { await run() }
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
.disabled(running)
|
||||
|
||||
ScrollView {
|
||||
Text(output.isEmpty ? "(暂无输出)" : output)
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
}
|
||||
.frame(maxHeight: 240)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.black.opacity(0.04))
|
||||
)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md)
|
||||
.fill(Color.yellow.opacity(0.08))
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.onAppear { refreshModelStatus() }
|
||||
.fileImporter(
|
||||
isPresented: $importingModel,
|
||||
allowedContentTypes: [.folder],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
importModelFolder(from: result)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshModelStatus() {
|
||||
modelReady = ModelStore.shared.isReady(.llm)
|
||||
}
|
||||
|
||||
private func importModelFolder(from result: Result<[URL], Error>) {
|
||||
defer {
|
||||
refreshModelStatus()
|
||||
importingModel = false
|
||||
}
|
||||
|
||||
do {
|
||||
guard let pickedURL = try result.get().first else {
|
||||
importError = "未选择模型文件夹"
|
||||
return
|
||||
}
|
||||
|
||||
let securityScoped = pickedURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if securityScoped {
|
||||
pickedURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
let sourceURL = resolvedModelSourceURL(from: pickedURL)
|
||||
guard FileManager.default.fileExists(
|
||||
atPath: sourceURL.appendingPathComponent(ModelKind.llm.sentinelFilename).path
|
||||
) else {
|
||||
importError = "请选择包含 config.json 的 Qwen3-1.7B-4bit 文件夹"
|
||||
return
|
||||
}
|
||||
|
||||
let targetURL = ModelStore.shared.localURL(for: .llm)
|
||||
let parentURL = targetURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: parentURL, withIntermediateDirectories: true)
|
||||
if FileManager.default.fileExists(atPath: targetURL.path) {
|
||||
try FileManager.default.removeItem(at: targetURL)
|
||||
}
|
||||
try FileManager.default.copyItem(at: sourceURL, to: targetURL)
|
||||
importError = nil
|
||||
} catch {
|
||||
importError = "导入失败:\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedModelSourceURL(from pickedURL: URL) -> URL {
|
||||
let nestedURL = pickedURL.appendingPathComponent(ModelKind.llm.rawValue, isDirectory: true)
|
||||
if FileManager.default.fileExists(
|
||||
atPath: nestedURL.appendingPathComponent(ModelKind.llm.sentinelFilename).path
|
||||
) {
|
||||
return nestedURL
|
||||
}
|
||||
return pickedURL
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func run() async {
|
||||
running = true
|
||||
output = ""
|
||||
rate = 0
|
||||
status = "加载模型..."
|
||||
do {
|
||||
try await AIRuntime.shared.prepare()
|
||||
status = "推理中..."
|
||||
|
||||
let prompt = "用中文一句话介绍肝功能里 ALT 这个指标。"
|
||||
for try await chunk in await AIRuntime.shared.generate(
|
||||
prompt: prompt,
|
||||
maxTokens: 200
|
||||
) {
|
||||
output += chunk.text
|
||||
rate = chunk.decodeRate
|
||||
}
|
||||
status = "完成 ✓"
|
||||
} catch {
|
||||
status = "失败:\(error.localizedDescription)"
|
||||
}
|
||||
running = false
|
||||
refreshModelStatus()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
146
康康/DesignSystem/Components.swift
Normal file
146
康康/DesignSystem/Components.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TjLockChip: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
Text("本地加密")
|
||||
.font(.system(size: 10))
|
||||
.tracking(0.5)
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(Tj.Palette.ink))
|
||||
}
|
||||
}
|
||||
|
||||
enum TjBadgeStyle {
|
||||
case brick, amber, leaf, ink, neutral
|
||||
|
||||
var bg: Color {
|
||||
switch self {
|
||||
case .brick: return Tj.Palette.brickSoft
|
||||
case .amber: return Color(red: 0.957, green: 0.890, blue: 0.749)
|
||||
case .leaf: return Tj.Palette.leafSoft
|
||||
case .ink: return Tj.Palette.ink
|
||||
case .neutral: return Tj.Palette.sand2
|
||||
}
|
||||
}
|
||||
var fg: Color {
|
||||
switch self {
|
||||
case .brick: return Tj.Palette.brick
|
||||
case .amber: return Tj.Palette.amber
|
||||
case .leaf: return Tj.Palette.leaf
|
||||
case .ink: return Tj.Palette.paper
|
||||
case .neutral: return Tj.Palette.text2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TjBadge: View {
|
||||
let text: String
|
||||
var style: TjBadgeStyle = .neutral
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(style.fg)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(style.bg))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
struct TjPlaceholder: View {
|
||||
let label: String
|
||||
var dark: Bool = false
|
||||
var radius: CGFloat = Tj.Radius.sm
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.fill(dark ? Color(red: 0.110, green: 0.122, blue: 0.110) : Tj.Palette.sand2)
|
||||
DiagonalStripes(spacing: 7, color: dark ? Color.white.opacity(0.04) : Color.black.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
|
||||
Text(label)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DiagonalStripes: View {
|
||||
let spacing: CGFloat
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Canvas { ctx, size in
|
||||
let step = spacing
|
||||
let count = Int((size.width + size.height) / step) + 4
|
||||
for i in -2..<count {
|
||||
let x = CGFloat(i) * step
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: x, y: 0))
|
||||
path.addLine(to: CGPoint(x: x + size.height, y: size.height))
|
||||
ctx.stroke(path, with: .color(color), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TjPrimaryButton: ButtonStyle {
|
||||
var height: CGFloat = 48
|
||||
var fontSize: CGFloat = 15
|
||||
var horizontalPadding: CGFloat = 22
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: fontSize, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.frame(height: height)
|
||||
.background(Capsule().fill(Tj.Palette.ink))
|
||||
.opacity(configuration.isPressed ? 0.85 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct TjGhostButton: ButtonStyle {
|
||||
var height: CGFloat = 48
|
||||
var fontSize: CGFloat = 15
|
||||
var horizontalPadding: CGFloat = 22
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: fontSize, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.frame(height: height)
|
||||
.background(
|
||||
Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1)
|
||||
)
|
||||
.opacity(configuration.isPressed ? 0.7 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct TjDashedDivider: View {
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(Tj.Palette.line)
|
||||
.frame(height: 1)
|
||||
.mask(
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<200, id: \.self) { _ in
|
||||
Rectangle().frame(width: 4, height: 1)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
62
康康/DesignSystem/Tokens.swift
Normal file
62
康康/DesignSystem/Tokens.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
|
||||
enum Tj {
|
||||
enum Palette {
|
||||
static let ink = Color(red: 0.165, green: 0.153, blue: 0.137)
|
||||
static let ink2 = Color(red: 0.286, green: 0.275, blue: 0.251)
|
||||
static let inkSoft = Color(red: 0.459, green: 0.447, blue: 0.424)
|
||||
static let sand = Color(red: 0.976, green: 0.969, blue: 0.949)
|
||||
static let sand2 = Color(red: 0.929, green: 0.918, blue: 0.886)
|
||||
static let sand3 = Color(red: 0.878, green: 0.859, blue: 0.816)
|
||||
static let paper = Color(red: 0.992, green: 0.988, blue: 0.973)
|
||||
static let line = Color(red: 0.875, green: 0.863, blue: 0.831)
|
||||
static let lineSoft = Color(red: 0.925, green: 0.918, blue: 0.890)
|
||||
static let text = Color(red: 0.149, green: 0.137, blue: 0.118)
|
||||
static let text2 = Color(red: 0.420, green: 0.408, blue: 0.384)
|
||||
static let text3 = Color(red: 0.616, green: 0.604, blue: 0.580)
|
||||
static let brick = Color(red: 0.886, green: 0.388, blue: 0.314)
|
||||
static let brickSoft = Color(red: 0.976, green: 0.863, blue: 0.824)
|
||||
static let amber = Color(red: 0.871, green: 0.627, blue: 0.314)
|
||||
static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518)
|
||||
static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941)
|
||||
static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059)
|
||||
}
|
||||
|
||||
enum Radius {
|
||||
static let xs: CGFloat = 8
|
||||
static let sm: CGFloat = 14
|
||||
static let md: CGFloat = 20
|
||||
static let lg: CGFloat = 28
|
||||
static let xl: CGFloat = 36
|
||||
static let pill: CGFloat = 999
|
||||
}
|
||||
|
||||
enum Shadow {
|
||||
static func card() -> some View {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Font {
|
||||
static func tjTitle(_ size: CGFloat = 30) -> Font { .system(size: size, weight: .bold, design: .default) }
|
||||
static func tjH2(_ size: CGFloat = 18) -> Font { .system(size: size, weight: .bold, design: .default) }
|
||||
static func tjMono(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .regular, design: .monospaced) }
|
||||
static func tjSerifBody(_ size: CGFloat = 17) -> Font { .system(size: size, weight: .regular, design: .default) }
|
||||
}
|
||||
|
||||
extension View {
|
||||
func tjCard(bordered: Bool = false, radius: CGFloat = Tj.Radius.md) -> some View {
|
||||
self
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0)
|
||||
)
|
||||
.shadow(color: bordered ? .clear : Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.05),
|
||||
radius: 2, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
61
康康/Features/Archive/ArchiveFlow.swift
Normal file
61
康康/Features/Archive/ArchiveFlow.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
private enum ArchiveStep: Hashable {
|
||||
case guide
|
||||
case scan
|
||||
case meta
|
||||
case progress
|
||||
case result
|
||||
}
|
||||
|
||||
struct ArchiveFlow: View {
|
||||
var onClose: () -> Void
|
||||
|
||||
@State private var step: ArchiveStep = .guide
|
||||
@State private var capturedPages: Int = 1
|
||||
@State private var totalPages: Int = 3
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch step {
|
||||
case .guide:
|
||||
B1GuideView(
|
||||
onSingle: { withAnimation { totalPages = 1; step = .scan } },
|
||||
onMulti: { withAnimation { totalPages = 3; step = .scan } },
|
||||
onSkip: onClose
|
||||
)
|
||||
.transition(.opacity)
|
||||
|
||||
case .scan:
|
||||
B2ScanView(
|
||||
onShoot: { capturedPages = min(capturedPages + 1, totalPages) },
|
||||
onDone: { withAnimation { step = .meta } },
|
||||
onClose: onClose,
|
||||
page: capturedPages,
|
||||
total: totalPages
|
||||
)
|
||||
.transition(.opacity)
|
||||
|
||||
case .meta:
|
||||
B3MetaView(
|
||||
onAnalyze: { withAnimation { step = .progress } },
|
||||
onBack: { withAnimation { step = .scan } }
|
||||
)
|
||||
.transition(.opacity)
|
||||
|
||||
case .progress:
|
||||
B4ProgressView(onComplete: {
|
||||
withAnimation { step = .result }
|
||||
})
|
||||
.transition(.opacity)
|
||||
|
||||
case .result:
|
||||
B5ResultView(
|
||||
onSave: onClose,
|
||||
onBack: { withAnimation { step = .meta } }
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
131
康康/Features/Archive/B1GuideView.swift
Normal file
131
康康/Features/Archive/B1GuideView.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
|
||||
struct B1GuideView: View {
|
||||
var onSingle: () -> Void
|
||||
var onMulti: () -> Void
|
||||
var onSkip: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Button(action: onSkip) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: onSkip) {
|
||||
Text("跳过")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.ink)
|
||||
Image(systemName: "doc.text.fill")
|
||||
.font(.system(size: 26, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
.frame(width: 60, height: 60)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Text("归档一份\n关键报告")
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.lineSpacing(6)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Text("推荐拍清晰的\(Text("整张图").underline()),多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineSpacing(6)
|
||||
.padding(.bottom, 26)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
OptCard(title: "单张报告", sub: "一张图,几秒搞定", hint: "化验单 · 处方", badge: nil, action: onSingle)
|
||||
OptCard(title: "多页报告", sub: "像扫描文档一样翻页拍摄", hint: "体检报告 · 影像报告", badge: "推荐", action: onMulti)
|
||||
}
|
||||
|
||||
Spacer(minLength: 18)
|
||||
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.padding(.top, 2)
|
||||
Text("所有照片以 AES 加密存于本机沙盒。Tiji 服务端无法访问。")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineSpacing(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
private struct OptCard: View {
|
||||
let title: String
|
||||
let sub: String
|
||||
let hint: String
|
||||
let badge: String?
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand2)
|
||||
Image(systemName: "doc.text")
|
||||
.font(.system(size: 18, weight: .regular))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
if let badge {
|
||||
TjBadge(text: badge, style: .ink)
|
||||
}
|
||||
}
|
||||
Text("\(sub) · \(hint)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(16)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
B1GuideView(
|
||||
onSingle: { print("单张报告") },
|
||||
onMulti: { print("多页报告") },
|
||||
onSkip: { print("跳过") }
|
||||
)
|
||||
}
|
||||
198
康康/Features/Archive/B2ScanView.swift
Normal file
198
康康/Features/Archive/B2ScanView.swift
Normal file
@@ -0,0 +1,198 @@
|
||||
import SwiftUI
|
||||
|
||||
struct B2ScanView: View {
|
||||
var onShoot: () -> Void
|
||||
var onDone: () -> Void
|
||||
var onClose: () -> Void
|
||||
var page: Int = 2
|
||||
var total: Int = 3
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea()
|
||||
|
||||
mockPaper
|
||||
|
||||
DetectedEdge()
|
||||
.stroke(Color(red: 0.95, green: 0.78, blue: 0.45),
|
||||
style: StrokeStyle(lineWidth: 2, dash: [6, 4]))
|
||||
.opacity(0.95)
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.top, 140)
|
||||
.padding(.bottom, 200)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
Spacer()
|
||||
detectedBadge
|
||||
Spacer()
|
||||
thumbnails
|
||||
bottomControls
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var mockPaper: some View {
|
||||
VStack(spacing: 2) {
|
||||
Text("体 检 报 告 (第 \(page) 页)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.padding(.bottom, 4)
|
||||
ForEach(reportRows, id: \.0) { row in
|
||||
HStack {
|
||||
Text(row.0).frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(row.1)
|
||||
Text(row.2).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.95))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
.rotation3DEffect(.degrees(8), axis: (x: 1, y: 0, z: 0))
|
||||
.rotationEffect(.degrees(-1))
|
||||
.shadow(color: .black.opacity(0.6), radius: 20, x: 0, y: 12)
|
||||
.padding(.horizontal, 40)
|
||||
.padding(.top, 160)
|
||||
.padding(.bottom, 220)
|
||||
}
|
||||
|
||||
private var reportRows: [(String, String, String)] {
|
||||
[
|
||||
("总胆固醇", "5.42", "3.10–5.18"),
|
||||
("甘油三酯", "1.78", "0.45–1.70"),
|
||||
("低密度脂蛋白", "3.84↑", "<3.40"),
|
||||
("高密度脂蛋白", "1.21", ">1.04"),
|
||||
("载脂蛋白 A1", "1.42", "1.00–1.60"),
|
||||
("载脂蛋白 B", "1.04", "0.55–1.05"),
|
||||
("谷丙转氨酶", "28", "9–50"),
|
||||
("谷草转氨酶", "24", "15–40"),
|
||||
("空腹血糖", "5.4", "3.9–6.1"),
|
||||
("糖化血红蛋白", "5.7", "4.0–6.0"),
|
||||
]
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
HStack {
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Text("\(page)").font(.system(size: 12, design: .monospaced))
|
||||
Text(" / \(total) · 像扫描文档一样对准")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.foregroundStyle(Color.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7)))
|
||||
Spacer()
|
||||
Color.clear.frame(width: 36, height: 36)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 50)
|
||||
}
|
||||
|
||||
private var detectedBadge: some View {
|
||||
Text("已识别边框 · 将自动透视校正")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(0.4)
|
||||
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(Color(red: 0.95, green: 0.78, blue: 0.45)))
|
||||
.padding(.top, 140)
|
||||
}
|
||||
|
||||
private var thumbnails: some View {
|
||||
HStack {
|
||||
PageThumbStack(index: 1)
|
||||
Spacer()
|
||||
Text("已拍 1 页")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
|
||||
private var bottomControls: some View {
|
||||
HStack {
|
||||
Color.clear.frame(width: 60, height: 60)
|
||||
Spacer()
|
||||
Button(action: onShoot) {
|
||||
ZStack {
|
||||
Circle().fill(Tj.Palette.paper)
|
||||
Circle().strokeBorder(Color.white.opacity(0.4), lineWidth: 4)
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
Button(action: onDone) {
|
||||
Text("完成")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(Capsule().fill(Color.white.opacity(0.1)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetectedEdge: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var p = Path()
|
||||
let w = rect.width
|
||||
let h = rect.height
|
||||
p.move(to: CGPoint(x: w * 0.04, y: h * 0.05))
|
||||
p.addLine(to: CGPoint(x: w * 0.92, y: h * 0.02))
|
||||
p.addLine(to: CGPoint(x: w * 0.96, y: h * 0.96))
|
||||
p.addLine(to: CGPoint(x: 0, y: h * 1.0))
|
||||
p.closeSubpath()
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
struct PageThumbStack: View {
|
||||
let index: Int
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.7))
|
||||
.frame(width: 56, height: 76)
|
||||
.rotationEffect(.degrees(2))
|
||||
.offset(x: 4, y: 4)
|
||||
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2)
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.85))
|
||||
.frame(width: 56, height: 76)
|
||||
.rotationEffect(.degrees(-1))
|
||||
.offset(x: 2, y: 2)
|
||||
.shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2)
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
.frame(width: 56, height: 76)
|
||||
.overlay(
|
||||
Text("p.\(index)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.4), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
.frame(width: 64, height: 84, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
137
康康/Features/Archive/B3MetaView.swift
Normal file
137
康康/Features/Archive/B3MetaView.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
struct B3MetaView: View {
|
||||
var onAnalyze: () -> Void
|
||||
var onBack: () -> Void
|
||||
|
||||
@State private var selectedType = 0
|
||||
private let types = ["体检报告", "化验单", "影像报告", "处方", "其他"]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("报告类型")
|
||||
.font(.system(size: 11))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
typeChips.padding(.bottom, 20)
|
||||
|
||||
FormRow(label: "报告日期", value: "2026 / 05 / 25", subtle: false)
|
||||
FormRow(label: "出具机构", value: "协和医院体检中心", subtle: true)
|
||||
FormRow(label: "备注", value: "春季年度体检", subtle: true)
|
||||
|
||||
Text("已拍页面(3 页)")
|
||||
.font(.system(size: 11))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
ForEach(1...3, id: \.self) { n in
|
||||
PageCard(index: n)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Button(action: onAnalyze) {
|
||||
Text("开始 AI 解读").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
|
||||
Text("预计耗时 5–8 秒 · 端侧 SME2 加速")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 6) {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Text("归档信息")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private var typeChips: some View {
|
||||
let columns = [GridItem(.adaptive(minimum: 60, maximum: 200), spacing: 8)]
|
||||
return LazyVGrid(columns: columns, alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(types.enumerated()), id: \.offset) { idx, t in
|
||||
Button { selectedType = idx } label: {
|
||||
Text(t)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(idx == selectedType ? Tj.Palette.paper : Tj.Palette.text2)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule().fill(idx == selectedType ? Tj.Palette.ink : Tj.Palette.sand2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FormRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let subtle: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
Text(value)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(subtle ? Tj.Palette.text3 : Tj.Palette.text)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PageCard: View {
|
||||
let index: Int
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06),
|
||||
radius: 2, x: 0, y: 1)
|
||||
TjPlaceholder(label: "p.\(index)", radius: 4)
|
||||
.padding(6)
|
||||
}
|
||||
.aspectRatio(0.72, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
293
康康/Features/Archive/B4ProgressView.swift
Normal file
293
康康/Features/Archive/B4ProgressView.swift
Normal file
@@ -0,0 +1,293 @@
|
||||
import SwiftUI
|
||||
|
||||
struct B4ProgressView: View {
|
||||
var onComplete: () -> Void
|
||||
|
||||
@State private var step: Int = 1
|
||||
@State private var pulse = false
|
||||
@State private var glow = false
|
||||
@State private var rotate: Double = 0
|
||||
@State private var elapsed: Double = 0.2
|
||||
|
||||
private let lineLabels = [
|
||||
"正在本地识别第 1 / 3 页…",
|
||||
"正在本地识别第 2 / 3 页…",
|
||||
"正在本地识别第 3 / 3 页…",
|
||||
"提取指标 · 共 28 项",
|
||||
"生成整体摘要…",
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
backgroundGradient.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
chip.padding(.bottom, 36)
|
||||
|
||||
Text("本地 AI · 正在解读")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(Color.white.opacity(0.95))
|
||||
.padding(.bottom, 6)
|
||||
|
||||
Text("QWEN2.5-VL · ON-DEVICE · SME2")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Color.white.opacity(0.55))
|
||||
.padding(.bottom, 30)
|
||||
|
||||
lineList
|
||||
.padding(.horizontal, 28)
|
||||
|
||||
speedBadge.padding(.top, 32)
|
||||
Spacer()
|
||||
|
||||
Text("本地处理中 · 不会上传任何内容")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Color.white.opacity(0.45))
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear { startAnimations() }
|
||||
}
|
||||
|
||||
private var backgroundGradient: some View {
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color(red: 0.22, green: 0.21, blue: 0.18),
|
||||
Color(red: 0.13, green: 0.12, blue: 0.10),
|
||||
Color(red: 0.08, green: 0.075, blue: 0.06),
|
||||
],
|
||||
center: .init(x: 0.5, y: 0.3),
|
||||
startRadius: 60,
|
||||
endRadius: 700
|
||||
)
|
||||
}
|
||||
|
||||
private var chip: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(red: 0.93, green: 0.75, blue: 0.40).opacity(glow ? 0.18 : 0.0))
|
||||
.frame(width: 176, height: 176)
|
||||
.blur(radius: 30)
|
||||
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.18),
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
|
||||
.frame(width: 140, height: 140)
|
||||
.rotationEffect(.degrees(rotate))
|
||||
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.36, green: 0.34, blue: 0.30),
|
||||
Color(red: 0.22, green: 0.21, blue: 0.18)],
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
.frame(width: 96, height: 96)
|
||||
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 12)
|
||||
.overlay(ChipGlyph())
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Circle()
|
||||
.fill(Color(red: 0.95, green: 0.78, blue: 0.40))
|
||||
.frame(width: 6, height: 6)
|
||||
.opacity(pulse ? 1 : 0.35)
|
||||
.shadow(color: Color(red: 0.95, green: 0.78, blue: 0.40), radius: 6)
|
||||
.padding(10)
|
||||
}
|
||||
.scaleEffect(pulse ? 1.06 : 1.0)
|
||||
.opacity(pulse ? 0.92 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
private var lineList: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(Array(lineLabels.enumerated()), id: \.offset) { idx, label in
|
||||
LineRow(
|
||||
text: label,
|
||||
done: step > idx + 1,
|
||||
active: step == idx + 1,
|
||||
isLast: idx == lineLabels.count - 1
|
||||
)
|
||||
.opacity(step >= idx + 1 ? 1 : 0)
|
||||
.offset(y: step >= idx + 1 ? 0 : 6)
|
||||
.animation(.easeOut(duration: 0.4).delay(Double(idx) * 0.05), value: step)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var speedBadge: some View {
|
||||
Text(String(format: "已处理 %.1fs · 比云端快 4.2×", elapsed))
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(Color.white.opacity(0.75))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Color.white.opacity(0.08)))
|
||||
}
|
||||
|
||||
private func startAnimations() {
|
||||
withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) {
|
||||
pulse.toggle()
|
||||
}
|
||||
withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) {
|
||||
glow.toggle()
|
||||
}
|
||||
withAnimation(.linear(duration: 14).repeatForever(autoreverses: false)) {
|
||||
rotate = 360
|
||||
}
|
||||
|
||||
Task {
|
||||
for _ in 0..<lineLabels.count {
|
||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
||||
await MainActor.run {
|
||||
withAnimation { step += 1 }
|
||||
elapsed += 0.9
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 600_000_000)
|
||||
await MainActor.run { onComplete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LineRow: View {
|
||||
let text: String
|
||||
let done: Bool
|
||||
let active: Bool
|
||||
let isLast: Bool
|
||||
|
||||
@State private var dotPulse = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(done
|
||||
? Color(red: 0.95, green: 0.78, blue: 0.40)
|
||||
: Color.white.opacity(0.12))
|
||||
if done {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094))
|
||||
}
|
||||
}
|
||||
.frame(width: 14, height: 14)
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(done ? Color.white.opacity(0.95) : Color.white.opacity(0.45))
|
||||
|
||||
if active {
|
||||
Spacer()
|
||||
Text("···")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Color.white.opacity(dotPulse ? 0.9 : 0.4))
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
|
||||
dotPulse.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChipGlyph: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.8), lineWidth: 1.4)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(Color(red: 0.95, green: 0.78, blue: 0.40).opacity(0.35))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.strokeBorder(Color(red: 0.95, green: 0.78, blue: 0.40), lineWidth: 1)
|
||||
)
|
||||
.frame(width: 16, height: 16)
|
||||
|
||||
innerCross
|
||||
outerPins
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
|
||||
private var innerCross: some View {
|
||||
Canvas { ctx, size in
|
||||
let amber = Color(red: 0.95, green: 0.78, blue: 0.40)
|
||||
let stroke = GraphicsContext.Shading.color(amber)
|
||||
let cx = size.width / 2
|
||||
let cy = size.height / 2
|
||||
|
||||
let pairs: [(CGPoint, CGPoint)] = [
|
||||
(CGPoint(x: cx, y: cy - 8), CGPoint(x: cx, y: cy - 4)),
|
||||
(CGPoint(x: cx, y: cy + 4), CGPoint(x: cx, y: cy + 8)),
|
||||
(CGPoint(x: cx - 8, y: cy), CGPoint(x: cx - 4, y: cy)),
|
||||
(CGPoint(x: cx + 4, y: cy), CGPoint(x: cx + 8, y: cy)),
|
||||
]
|
||||
for (s, e) in pairs {
|
||||
var p = Path()
|
||||
p.move(to: s)
|
||||
p.addLine(to: e)
|
||||
ctx.stroke(p, with: stroke, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||||
}
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
|
||||
private var outerPins: some View {
|
||||
Canvas { ctx, size in
|
||||
let pinColor = GraphicsContext.Shading.color(Color.white.opacity(0.45))
|
||||
let cx = size.width / 2
|
||||
let cy = size.height / 2
|
||||
let halfChip: CGFloat = 14
|
||||
let outsideStart: CGFloat = 20
|
||||
let outsideEnd: CGFloat = 26
|
||||
|
||||
let positions: [CGFloat] = [-8, 0, 8]
|
||||
|
||||
for offset in positions {
|
||||
// top
|
||||
var p = Path()
|
||||
p.move(to: CGPoint(x: cx + offset, y: cy - outsideEnd))
|
||||
p.addLine(to: CGPoint(x: cx + offset, y: cy - halfChip))
|
||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||||
|
||||
// bottom
|
||||
p = Path()
|
||||
p.move(to: CGPoint(x: cx + offset, y: cy + halfChip))
|
||||
p.addLine(to: CGPoint(x: cx + offset, y: cy + outsideEnd))
|
||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||||
|
||||
// left
|
||||
p = Path()
|
||||
p.move(to: CGPoint(x: cx - outsideEnd, y: cy + offset))
|
||||
p.addLine(to: CGPoint(x: cx - halfChip, y: cy + offset))
|
||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||||
|
||||
// right
|
||||
p = Path()
|
||||
p.move(to: CGPoint(x: cx + halfChip, y: cy + offset))
|
||||
p.addLine(to: CGPoint(x: cx + outsideStart + 2, y: cy + offset))
|
||||
ctx.stroke(p, with: pinColor, style: StrokeStyle(lineWidth: 1, lineCap: .round))
|
||||
}
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
B4ProgressView(onComplete: {})
|
||||
}
|
||||
323
康康/Features/Archive/B5ResultView.swift
Normal file
323
康康/Features/Archive/B5ResultView.swift
Normal file
@@ -0,0 +1,323 @@
|
||||
import SwiftUI
|
||||
|
||||
struct B5IndicatorData {
|
||||
let name: String
|
||||
let value: String
|
||||
let unit: String
|
||||
let range: String
|
||||
let status: IndicatorStatus
|
||||
let note: String?
|
||||
}
|
||||
|
||||
struct B5ResultView: View {
|
||||
var onSave: () -> Void
|
||||
var onBack: () -> Void
|
||||
|
||||
@State private var expandedIndex: Int? = 0
|
||||
@State private var normalsExpanded = false
|
||||
|
||||
let abnormal: [B5IndicatorData] = [
|
||||
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high,
|
||||
note: "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。"),
|
||||
.init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "0.45–1.70", status: .high, note: nil),
|
||||
.init(name: "尿酸 UA", value: "428", unit: "μmol/L", range: "150–420", status: .high, note: nil),
|
||||
.init(name: "维生素 D", value: "18", unit: "ng/mL", range: "30–100", status: .low, note: nil),
|
||||
]
|
||||
let normalCount = 24
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
reportMeta.padding(.bottom, 16)
|
||||
summaryCard.padding(.bottom, 18)
|
||||
SectionLabel("异常项", count: abnormal.count, accent: .brick)
|
||||
.padding(.bottom, 10)
|
||||
VStack(spacing: 8) {
|
||||
ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in
|
||||
IndicatorRow(item: it, expanded: expandedIndex == idx) {
|
||||
withAnimation { expandedIndex = (expandedIndex == idx) ? nil : idx }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 18)
|
||||
|
||||
SectionLabel("正常项", count: normalCount, accent: .leaf)
|
||||
.padding(.bottom, 10)
|
||||
normalCollapsed
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button(action: onSave) {
|
||||
Text("保存归档").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
|
||||
Button { } label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(TjGhostButton(horizontalPadding: 16))
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 14)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 6) {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Spacer()
|
||||
Button { } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "photo")
|
||||
Text("查看原图")
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private var reportMeta: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
TjBadge(text: "体检报告", style: .ink)
|
||||
Text("3 页")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
TjLockChip()
|
||||
}
|
||||
Text("2026 春季年度体检")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("2026 / 05 / 25 · 协和医院体检中心")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 10) {
|
||||
Text("整体摘记")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.fixedSize()
|
||||
Rectangle().fill(Tj.Palette.line).frame(height: 1)
|
||||
Text("本机摘要")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.fixedSize()
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
|
||||
HStack(spacing: 14) {
|
||||
Stat(n: "28", label: "总项")
|
||||
Stat(n: "3", label: "偏高", tone: .brick)
|
||||
Stat(n: "1", label: "偏低", tone: .amber)
|
||||
Stat(n: "24", label: "正常", tone: .leaf)
|
||||
}
|
||||
.padding(.bottom, 14)
|
||||
|
||||
Text("本次共检测 28 项,\(Text("3 项偏高").fontWeight(.semibold).underline(color: Tj.Palette.brick))(血脂相关 2 项 + 尿酸)、\(Text("1 项偏低").fontWeight(.semibold).underline(color: Tj.Palette.amber))(维生素 D)。整体趋势提示代谢风险有所抬升,建议优化饮食并复查血脂。")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineSpacing(6)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
TjDashedDivider().padding(.bottom, 10)
|
||||
|
||||
Text("仅供参考,不构成医疗建议")
|
||||
.font(.system(size: 11))
|
||||
.italic()
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.leading, 20)
|
||||
.padding(.trailing, 20)
|
||||
.padding(.vertical, 20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
Tj.Palette.paper
|
||||
.overlay(alignment: .leading) {
|
||||
Tj.Palette.brick.frame(width: 3)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
|
||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), radius: 0, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private var normalCollapsed: some View {
|
||||
Button { withAnimation { normalsExpanded.toggle() } } label: {
|
||||
HStack(spacing: 10) {
|
||||
TjBadge(text: "\(normalCount)", style: .leaf)
|
||||
Text("谷丙转氨酶、空腹血糖、糖化血红蛋白…")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Image(systemName: normalsExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Stat: View {
|
||||
let n: String
|
||||
let label: String
|
||||
var tone: Tone = .ink
|
||||
|
||||
enum Tone { case ink, brick, amber, leaf }
|
||||
|
||||
var color: Color {
|
||||
switch tone {
|
||||
case .ink: return Tj.Palette.text
|
||||
case .brick: return Tj.Palette.brick
|
||||
case .amber: return Color(red: 0.59, green: 0.45, blue: 0.27)
|
||||
case .leaf: return Tj.Palette.leaf
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(n)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
Text(label)
|
||||
.font(.system(size: 10))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SectionLabel: View {
|
||||
let title: String
|
||||
let count: Int
|
||||
let accent: AccentKind
|
||||
|
||||
enum AccentKind { case brick, leaf }
|
||||
|
||||
init(_ title: String, count: Int, accent: AccentKind) {
|
||||
self.title = title
|
||||
self.count = count
|
||||
self.accent = accent
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(accent == .brick ? Tj.Palette.brick : Tj.Palette.leaf)
|
||||
.frame(width: 4, height: 14)
|
||||
Text(title).font(.system(size: 13, weight: .semibold)).foregroundStyle(Tj.Palette.text)
|
||||
Text("· \(count)").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IndicatorRow: View {
|
||||
let item: B5IndicatorData
|
||||
let expanded: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var statusBadge: TjBadgeStyle {
|
||||
switch item.status {
|
||||
case .high: return .brick
|
||||
case .low: return .amber
|
||||
case .normal: return .leaf
|
||||
}
|
||||
}
|
||||
var statusWord: String {
|
||||
switch item.status {
|
||||
case .high: return "偏高"
|
||||
case .low: return "偏低"
|
||||
case .normal: return "正常"
|
||||
}
|
||||
}
|
||||
var valueColor: Color {
|
||||
switch item.status {
|
||||
case .high: return Tj.Palette.brick
|
||||
case .low: return Color(red: 0.55, green: 0.45, blue: 0.32)
|
||||
case .normal: return Tj.Palette.text
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Text(item.name)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
TjBadge(text: statusWord, style: statusBadge)
|
||||
}
|
||||
Text("范围 \(item.range) \(item.unit)")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(item.value)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(valueColor)
|
||||
Text(item.unit)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
|
||||
if expanded, let note = item.note {
|
||||
TjDashedDivider()
|
||||
Text(note)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineSpacing(5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(
|
||||
item.status != .normal
|
||||
? Color(red: 0.78, green: 0.68, blue: 0.48).opacity(0.5)
|
||||
: Tj.Palette.lineSoft,
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
155
康康/Features/Home/HomeView.swift
Normal file
155
康康/Features/Home/HomeView.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HomeView: View {
|
||||
var onTapArchive: () -> Void = {}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
greeting
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
todaySummaryCard
|
||||
.padding(.bottom, 18)
|
||||
|
||||
recentSection
|
||||
.padding(.bottom, 22)
|
||||
|
||||
archiveSection
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private var greeting: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("5 月 25 日 · 周一")
|
||||
.font(.system(size: 12))
|
||||
.tracking(1)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("早安,林意")
|
||||
.font(.tjTitle())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
Spacer()
|
||||
TjLockChip()
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var todaySummaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 10) {
|
||||
Text("今日 · 摘记")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
.fixedSize()
|
||||
Rectangle()
|
||||
.fill(Tj.Palette.line)
|
||||
.frame(height: 1)
|
||||
Text("本机摘要")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.fixedSize()
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
|
||||
Text("上次体检后,\(Text("低密度脂蛋白").underline(color: Tj.Palette.brick).foregroundColor(Tj.Palette.text))持续偏高已 3 个月。建议本周记录一次空腹血脂。")
|
||||
.font(.tjSerifBody())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineSpacing(6)
|
||||
.padding(.bottom, 14)
|
||||
|
||||
HStack(spacing: 14) {
|
||||
Button("记录今日") {}
|
||||
.buttonStyle(TjPrimaryButton(height: 34, fontSize: 13, horizontalPadding: 14))
|
||||
Button("查看趋势") {}
|
||||
.buttonStyle(TjGhostButton(height: 34, fontSize: 13, horizontalPadding: 14))
|
||||
}
|
||||
}
|
||||
.padding(.leading, 20)
|
||||
.padding(.trailing, 18)
|
||||
.padding(.vertical, 18)
|
||||
.background(
|
||||
Tj.Palette.paper
|
||||
.overlay(alignment: .leading) {
|
||||
Tj.Palette.brick.frame(width: 3)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous))
|
||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), radius: 0, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private var recentSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Text("全部 ›")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
RecentItemRow(
|
||||
date: "昨天 18:20",
|
||||
type: "异常项快拍",
|
||||
name: "低密度脂蛋白 LDL-C",
|
||||
value: "3.84 mmol/L",
|
||||
status: .high
|
||||
)
|
||||
RecentItemRow(
|
||||
date: "5 月 23 日",
|
||||
type: "关键报告归档",
|
||||
name: "春季年度体检 · 共 3 页",
|
||||
value: "3 项偏高",
|
||||
status: .archive
|
||||
)
|
||||
RecentItemRow(
|
||||
date: "5 月 22 日",
|
||||
type: "文字日记",
|
||||
name: "头痛 · 上午 10 点起,午后缓解",
|
||||
value: nil,
|
||||
status: .diary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var archiveSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||||
|
||||
Button(action: onTapArchive) {
|
||||
HStack(spacing: 14) {
|
||||
TjPlaceholder(label: "档案 · 12")
|
||||
.frame(width: 56, height: 56)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("我的报告档案")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("12 份 · 218 项指标 · 端侧加密")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HomeView()
|
||||
}
|
||||
59
康康/Features/Home/RecentItemRow.swift
Normal file
59
康康/Features/Home/RecentItemRow.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
enum RecentItemStatus {
|
||||
case high, archive, diary
|
||||
|
||||
var dotColor: Color {
|
||||
switch self {
|
||||
case .high: return Tj.Palette.brick
|
||||
case .archive: return Tj.Palette.ink2
|
||||
case .diary: return Tj.Palette.leaf
|
||||
}
|
||||
}
|
||||
|
||||
var valueColor: Color {
|
||||
switch self {
|
||||
case .high: return Tj.Palette.brick
|
||||
default: return Tj.Palette.text2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentItemRow: View {
|
||||
let date: String
|
||||
let type: String
|
||||
let name: String
|
||||
let value: String?
|
||||
let status: RecentItemStatus
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(status.dotColor)
|
||||
.frame(width: 6, height: 40)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(date) · \(type)")
|
||||
.font(.system(size: 11))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.lineLimit(1)
|
||||
Text(name)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(status.valueColor)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
}
|
||||
20
康康/Features/Me/MeView.swift
Normal file
20
康康/Features/Me/MeView.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MeView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
TjPlaceholder(label: "我的 · 模型管理 / Face ID / 关于\n(W6 实现)")
|
||||
.frame(width: 280, height: 180)
|
||||
|
||||
#if DEBUG
|
||||
DebugAIRunner()
|
||||
#endif
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { MeView() }
|
||||
159
康康/Features/Quick/A1ViewfinderView.swift
Normal file
159
康康/Features/Quick/A1ViewfinderView.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct A1ViewfinderView: View {
|
||||
var onShoot: () -> Void
|
||||
var onClose: () -> Void
|
||||
|
||||
@State private var dotPulse = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea()
|
||||
|
||||
mockCameraPreview(screenHeight: geometry.size.height)
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 50)
|
||||
|
||||
topHint
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
SmartFramer()
|
||||
.allowsHitTesting(false)
|
||||
.ignoresSafeArea()
|
||||
|
||||
identifiedPill
|
||||
.padding(.top, geometry.size.height * 0.62 - 20)
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
bottomControls
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.statusBarHidden(false)
|
||||
#endif
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private func mockCameraPreview(screenHeight: CGFloat) -> some View {
|
||||
RadialGradient(
|
||||
colors: [Color.white.opacity(0.05), Color.clear],
|
||||
center: .init(x: 0.5, y: 0.3),
|
||||
startRadius: 20,
|
||||
endRadius: 400
|
||||
)
|
||||
.overlay(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("总胆固醇 TC 5.42 mmol/L").opacity(0.65)
|
||||
Text("甘油三酯 TG 1.78 mmol/L").opacity(0.65)
|
||||
Text("低密度脂蛋白 3.84 mmol/L ↑").fontWeight(.semibold).opacity(1)
|
||||
Text("高密度脂蛋白 1.21 mmol/L").opacity(0.65)
|
||||
Text("载脂蛋白 A1 1.42 g/L").opacity(0.45)
|
||||
Text("载脂蛋白 B 1.04 g/L").opacity(0.45)
|
||||
}
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.vertical, 20)
|
||||
.padding(.horizontal, 18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.92))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
.rotationEffect(.degrees(-1.2))
|
||||
.shadow(color: .black.opacity(0.45), radius: 15, x: 0, y: 12)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, screenHeight * 0.20)
|
||||
}
|
||||
}
|
||||
|
||||
private var topHint: some View {
|
||||
Text("对准异常的那一行就好 · 不用拍整张")
|
||||
.font(.system(size: 12))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Color.white.opacity(0.92))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7)))
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
private var identifiedPill: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Tj.Palette.paper)
|
||||
.frame(width: 6, height: 6)
|
||||
.opacity(dotPulse ? 1 : 0.35)
|
||||
Text("AI 已识别到 1 项指标")
|
||||
.font(.system(size: 11))
|
||||
.tracking(0.5)
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Color(red: 0.37, green: 0.47, blue: 0.31).opacity(0.85)))
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) {
|
||||
dotPulse.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomControls: some View {
|
||||
HStack {
|
||||
CircleIconButton(icon: "bolt.fill", size: 44) { }
|
||||
Spacer()
|
||||
Button(action: onShoot) {
|
||||
ZStack {
|
||||
Circle().fill(Tj.Palette.ink)
|
||||
Circle().strokeBorder(Tj.Palette.paper, lineWidth: 4)
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
.overlay(
|
||||
Circle().strokeBorder(Color.white.opacity(0.2), lineWidth: 1)
|
||||
.frame(width: 76, height: 76)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
CircleIconButton(icon: "photo.on.rectangle", size: 44) { }
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircleIconButton: View {
|
||||
let icon: String
|
||||
let size: CGFloat
|
||||
let action: () -> Void
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
ZStack {
|
||||
Circle().fill(Color.white.opacity(0.12))
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
180
康康/Features/Quick/A2ConfirmView.swift
Normal file
180
康康/Features/Quick/A2ConfirmView.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import SwiftUI
|
||||
|
||||
struct A2ConfirmView: View {
|
||||
var onSave: () -> Void
|
||||
var onNext: () -> Void
|
||||
var onBack: () -> Void
|
||||
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
croppedPhoto.padding(.bottom, 14)
|
||||
resultCard.padding(.bottom, 16)
|
||||
actions
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 6) {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Text("核对识别结果")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Text("识别用时 0.4s · 本地")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Tj.Palette.sand2))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private var croppedPhoto: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Text("低密度脂蛋白 3.84 mmol/L ↑")
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.fontWeight(.semibold)
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.92))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous))
|
||||
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06),
|
||||
radius: 2, x: 0, y: 1)
|
||||
Text("已裁剪")
|
||||
.font(.system(size: 9))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.top, 8)
|
||||
.padding(.trailing, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private var resultCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("指标名 · 可编辑")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text("低密度脂蛋白胆固醇")
|
||||
.font(.system(size: 19, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("LDL-C")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
TjBadge(text: "偏高", style: .brick)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
FieldBox(label: "数值") {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("3.84")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
Text("mmol/L")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
FieldBox(label: "参考范围") {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("< 3.40")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Text("mmol/L")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button { withAnimation { expanded.toggle() } } label: {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(Tj.Palette.brick)
|
||||
.frame(width: 4)
|
||||
Text(expanded
|
||||
? "超过参考上限 0.44,属轻度偏高。建议关注饮食结构(减少动物脂肪摄入),3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。"
|
||||
: "超过参考上限 0.44,属轻度偏高。点击展开详细解读 ›")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
.lineSpacing(5)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(Tj.Palette.sand)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(18)
|
||||
.tjCard()
|
||||
}
|
||||
|
||||
private var actions: some View {
|
||||
VStack(spacing: 10) {
|
||||
Button(action: onSave) {
|
||||
Text("保存到记录")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
|
||||
Button(action: onNext) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "camera.fill").font(.system(size: 14))
|
||||
Text("继续拍下一项")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjGhostButton())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FieldBox<Content: View>: View {
|
||||
let label: String
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label)
|
||||
.font(.system(size: 10))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
124
康康/Features/Quick/A3BatchView.swift
Normal file
124
康康/Features/Quick/A3BatchView.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import SwiftUI
|
||||
|
||||
struct A3BatchItem {
|
||||
let name: String
|
||||
let value: String
|
||||
let unit: String
|
||||
let range: String
|
||||
let status: IndicatorStatus
|
||||
}
|
||||
|
||||
struct A3BatchView: View {
|
||||
var onAddMore: () -> Void
|
||||
var onFinish: () -> Void
|
||||
var onBack: () -> Void
|
||||
|
||||
let items: [A3BatchItem] = [
|
||||
.init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high),
|
||||
.init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "< 1.70", status: .high),
|
||||
.init(name: "空腹血糖 GLU", value: "5.4", unit: "mmol/L", range: "3.9–6.1", status: .normal),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { idx, it in
|
||||
BatchRow(index: idx + 1, item: it)
|
||||
}
|
||||
addRow
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
onFinish()
|
||||
} label: {
|
||||
Text("全部保存(\(items.count))").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(TjPrimaryButton())
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 6) {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("本次已记录 \(items.count) 项")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("核对后一次保存")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Text("· · ·")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
private var addRow: some View {
|
||||
Button(action: onAddMore) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "camera").font(.system(size: 14))
|
||||
Text("再拍一项")
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.line, style: StrokeStyle(lineWidth: 1.5, dash: [4, 4]))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BatchRow: View {
|
||||
let index: Int
|
||||
let item: A3BatchItem
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
TjPlaceholder(label: "#\(index)")
|
||||
.frame(width: 60, height: 44)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.name)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text("范围 \(item.range) \(item.unit)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(item.value)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(item.status == .high ? Tj.Palette.brick : Tj.Palette.text)
|
||||
TjBadge(text: item.status == .high ? "偏高" : "正常",
|
||||
style: item.status == .high ? .brick : .leaf)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.tjCard()
|
||||
}
|
||||
}
|
||||
60
康康/Features/Quick/QuickCaptureFlow.swift
Normal file
60
康康/Features/Quick/QuickCaptureFlow.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
private enum QuickStep: Hashable {
|
||||
case viewfinder
|
||||
case confirm
|
||||
case batch
|
||||
}
|
||||
|
||||
struct QuickCaptureFlow: View {
|
||||
var onClose: () -> Void
|
||||
|
||||
@State private var step: QuickStep = .viewfinder
|
||||
@State private var snapCount = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch step {
|
||||
case .viewfinder:
|
||||
A1ViewfinderView(
|
||||
onShoot: {
|
||||
snapCount += 1
|
||||
withAnimation(.easeInOut(duration: 0.25)) { step = .confirm }
|
||||
},
|
||||
onClose: onClose
|
||||
)
|
||||
.transition(.opacity)
|
||||
|
||||
case .confirm:
|
||||
A2ConfirmView(
|
||||
onSave: {
|
||||
if snapCount >= 2 {
|
||||
withAnimation { step = .batch }
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
onNext: {
|
||||
withAnimation { step = .viewfinder }
|
||||
},
|
||||
onBack: {
|
||||
withAnimation { step = .viewfinder }
|
||||
}
|
||||
)
|
||||
.transition(.opacity)
|
||||
|
||||
case .batch:
|
||||
A3BatchView(
|
||||
onAddMore: {
|
||||
withAnimation { step = .viewfinder }
|
||||
},
|
||||
onFinish: onClose,
|
||||
onBack: {
|
||||
withAnimation { step = .confirm }
|
||||
}
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
康康/Features/Quick/SmartFramer.swift
Normal file
100
康康/Features/Quick/SmartFramer.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SmartFramer: View {
|
||||
var radius: CGFloat = 10
|
||||
var height: CGFloat = 56
|
||||
@State private var breathing = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
Color.black.opacity(0.32)
|
||||
.mask(
|
||||
Rectangle()
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.frame(height: height)
|
||||
.padding(.horizontal, geo.size.width * 0.08)
|
||||
.blendMode(.destinationOut)
|
||||
)
|
||||
.compositingGroup()
|
||||
)
|
||||
|
||||
RoundedRectangle(cornerRadius: radius + 4, style: .continuous)
|
||||
.stroke(Color(red: 0.95, green: 0.78, blue: 0.45), lineWidth: 1.5)
|
||||
.shadow(color: Color(red: 0.95, green: 0.78, blue: 0.45).opacity(0.5), radius: 8)
|
||||
.frame(height: height + 8)
|
||||
.padding(.horizontal, geo.size.width * 0.08 - 4)
|
||||
.opacity(breathing ? 1 : 0.35)
|
||||
|
||||
cornerMarks(in: geo.size)
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) {
|
||||
breathing.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cornerMarks(in size: CGSize) -> some View {
|
||||
let inset = size.width * 0.08
|
||||
return ZStack {
|
||||
ForEach(Corner.allCases, id: \.self) { corner in
|
||||
CornerMark(corner: corner, radius: radius)
|
||||
.frame(width: 18, height: 18)
|
||||
.position(corner.position(in: size, inset: inset, frameHeight: height))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Corner: CaseIterable {
|
||||
case tl, tr, bl, br
|
||||
func position(in size: CGSize, inset: CGFloat, frameHeight: CGFloat) -> CGPoint {
|
||||
let centerY = size.height / 2
|
||||
let top = centerY - frameHeight / 2
|
||||
let bottom = centerY + frameHeight / 2
|
||||
switch self {
|
||||
case .tl: return CGPoint(x: inset, y: top)
|
||||
case .tr: return CGPoint(x: size.width - inset, y: top)
|
||||
case .bl: return CGPoint(x: inset, y: bottom)
|
||||
case .br: return CGPoint(x: size.width - inset, y: bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CornerMark: View {
|
||||
let corner: Corner
|
||||
let radius: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Path { p in
|
||||
let r = min(radius, 8)
|
||||
switch corner {
|
||||
case .tl:
|
||||
p.move(to: CGPoint(x: 0, y: 18))
|
||||
p.addLine(to: CGPoint(x: 0, y: r))
|
||||
p.addQuadCurve(to: CGPoint(x: r, y: 0), control: CGPoint(x: 0, y: 0))
|
||||
p.addLine(to: CGPoint(x: 18, y: 0))
|
||||
case .tr:
|
||||
p.move(to: CGPoint(x: 0, y: 0))
|
||||
p.addLine(to: CGPoint(x: 18 - r, y: 0))
|
||||
p.addQuadCurve(to: CGPoint(x: 18, y: r), control: CGPoint(x: 18, y: 0))
|
||||
p.addLine(to: CGPoint(x: 18, y: 18))
|
||||
case .bl:
|
||||
p.move(to: CGPoint(x: 0, y: 0))
|
||||
p.addLine(to: CGPoint(x: 0, y: 18 - r))
|
||||
p.addQuadCurve(to: CGPoint(x: r, y: 18), control: CGPoint(x: 0, y: 18))
|
||||
p.addLine(to: CGPoint(x: 18, y: 18))
|
||||
case .br:
|
||||
p.move(to: CGPoint(x: 0, y: 18))
|
||||
p.addLine(to: CGPoint(x: 18 - r, y: 18))
|
||||
p.addQuadCurve(to: CGPoint(x: 18, y: 18 - r), control: CGPoint(x: 18, y: 18))
|
||||
p.addLine(to: CGPoint(x: 18, y: 0))
|
||||
}
|
||||
}
|
||||
.stroke(Tj.Palette.paper, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||
}
|
||||
}
|
||||
106
康康/Features/Record/RecordSheet.swift
Normal file
106
康康/Features/Record/RecordSheet.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import SwiftUI
|
||||
|
||||
enum RecordKind: String, Identifiable, CaseIterable {
|
||||
case quick, archive, diary
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .quick: return "异常项快拍"
|
||||
case .archive: return "关键报告归档"
|
||||
case .diary: return "文字日记"
|
||||
}
|
||||
}
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .quick: return "只记录单个或几项异常指标"
|
||||
case .archive: return "完整保存整份报告(可多页)"
|
||||
case .diary: return "记录症状、心情、用药"
|
||||
}
|
||||
}
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .quick: return "camera.fill"
|
||||
case .archive: return "doc.fill"
|
||||
case .diary: return "pencil"
|
||||
}
|
||||
}
|
||||
var accent: Color {
|
||||
switch self {
|
||||
case .quick: return Tj.Palette.brick
|
||||
case .archive: return Tj.Palette.ink
|
||||
case .diary: return Tj.Palette.leaf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordSheet: View {
|
||||
var onPick: (RecordKind) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Capsule()
|
||||
.fill(Tj.Palette.line)
|
||||
.frame(width: 40, height: 4)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
HStack {
|
||||
Text("记录什么?")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Text("本地处理 · 永不上传")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(.bottom, 14)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
ForEach(RecordKind.allCases) { kind in
|
||||
Button {
|
||||
onPick(kind)
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
||||
.fill(kind.accent)
|
||||
Image(systemName: kind.icon)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(kind.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text(kind.subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(16)
|
||||
.tjCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 22)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.background(
|
||||
Tj.Palette.sand
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
)
|
||||
.presentationDetents([.fraction(0.55)])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
}
|
||||
}
|
||||
19
康康/Features/Trends/TrendsView.swift
Normal file
19
康康/Features/Trends/TrendsView.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TrendsView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Spacer()
|
||||
TjPlaceholder(label: "trends · 折线图 + 影像档案入口\n(尚未实现)")
|
||||
.frame(width: 280, height: 180)
|
||||
Text("趋势")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { TrendsView() }
|
||||
156
康康/Models/Models.swift
Normal file
156
康康/Models/Models.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
enum IndicatorStatus: String, Codable, CaseIterable {
|
||||
case high, low, normal
|
||||
}
|
||||
|
||||
enum ReportType: String, Codable, CaseIterable {
|
||||
case checkup, lab, imaging, prescription, other
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .checkup: return "体检报告"
|
||||
case .lab: return "化验单"
|
||||
case .imaging: return "影像报告"
|
||||
case .prescription: return "处方"
|
||||
case .other: return "其他"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class Indicator {
|
||||
var name: String
|
||||
var value: String
|
||||
var unit: String
|
||||
var range: String
|
||||
var statusRaw: String
|
||||
var note: String?
|
||||
var capturedAt: Date
|
||||
|
||||
var report: Report?
|
||||
var asset: Asset?
|
||||
var pinned: Bool = false
|
||||
|
||||
init(name: String,
|
||||
value: String,
|
||||
unit: String,
|
||||
range: String,
|
||||
status: IndicatorStatus,
|
||||
note: String? = nil,
|
||||
capturedAt: Date = .now,
|
||||
report: Report? = nil,
|
||||
asset: Asset? = nil,
|
||||
pinned: Bool = false) {
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.unit = unit
|
||||
self.range = range
|
||||
self.statusRaw = status.rawValue
|
||||
self.note = note
|
||||
self.capturedAt = capturedAt
|
||||
self.report = report
|
||||
self.asset = asset
|
||||
self.pinned = pinned
|
||||
}
|
||||
|
||||
var status: IndicatorStatus {
|
||||
IndicatorStatus(rawValue: statusRaw) ?? .normal
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class Report {
|
||||
var title: String
|
||||
var typeRaw: String
|
||||
var reportDate: Date
|
||||
var institution: String?
|
||||
var note: String?
|
||||
var summary: String?
|
||||
var pageCount: Int
|
||||
var createdAt: Date
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Indicator.report)
|
||||
var indicators: [Indicator] = []
|
||||
|
||||
@Relationship(deleteRule: .cascade)
|
||||
var assets: [Asset] = []
|
||||
|
||||
init(title: String,
|
||||
type: ReportType,
|
||||
reportDate: Date,
|
||||
institution: String? = nil,
|
||||
note: String? = nil,
|
||||
summary: String? = nil,
|
||||
pageCount: Int = 1,
|
||||
createdAt: Date = .now) {
|
||||
self.title = title
|
||||
self.typeRaw = type.rawValue
|
||||
self.reportDate = reportDate
|
||||
self.institution = institution
|
||||
self.note = note
|
||||
self.summary = summary
|
||||
self.pageCount = pageCount
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
var type: ReportType {
|
||||
ReportType(rawValue: typeRaw) ?? .other
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class DiaryEntry {
|
||||
var content: String
|
||||
var createdAt: Date
|
||||
var tags: [String]
|
||||
|
||||
init(content: String, createdAt: Date = .now, tags: [String] = []) {
|
||||
self.content = content
|
||||
self.createdAt = createdAt
|
||||
self.tags = tags
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class Asset {
|
||||
var relativePath: String
|
||||
var mimeType: String
|
||||
var bytes: Int
|
||||
var createdAt: Date
|
||||
|
||||
init(relativePath: String,
|
||||
mimeType: String = "image/jpeg",
|
||||
bytes: Int = 0,
|
||||
createdAt: Date = .now) {
|
||||
self.relativePath = relativePath
|
||||
self.mimeType = mimeType
|
||||
self.bytes = bytes
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class ChatTurn {
|
||||
var question: String
|
||||
var answer: String
|
||||
var referencedIndicatorIDs: [String]
|
||||
var referencedReportIDs: [String]
|
||||
var createdAt: Date
|
||||
var decodeRate: Double
|
||||
|
||||
init(question: String,
|
||||
answer: String,
|
||||
referencedIndicatorIDs: [String] = [],
|
||||
referencedReportIDs: [String] = [],
|
||||
createdAt: Date = .now,
|
||||
decodeRate: Double = 0) {
|
||||
self.question = question
|
||||
self.answer = answer
|
||||
self.referencedIndicatorIDs = referencedIndicatorIDs
|
||||
self.referencedReportIDs = referencedReportIDs
|
||||
self.createdAt = createdAt
|
||||
self.decodeRate = decodeRate
|
||||
}
|
||||
}
|
||||
101
康康/Persistence/FileVault.swift
Normal file
101
康康/Persistence/FileVault.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum FileVaultError: Error {
|
||||
case readFailed
|
||||
case writeFailed
|
||||
case removeFailed
|
||||
case decodeFailed
|
||||
}
|
||||
|
||||
/// `@unchecked Sendable`:rootURL 是 let,方法只 I/O 到沙盒目录(线程安全),
|
||||
/// 可被任意 actor / Task 跨边界访问。
|
||||
/// `nonisolated(unsafe) shared`:见 ModelStore 同款注释。
|
||||
final class FileVault: @unchecked Sendable {
|
||||
nonisolated(unsafe) static let shared: FileVault = {
|
||||
do {
|
||||
let appSupport = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let vaultURL = appSupport.appendingPathComponent("Vault", isDirectory: true)
|
||||
return try FileVault(rootURL: vaultURL)
|
||||
} catch {
|
||||
fatalError("FileVault.shared init failed: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
let rootURL: URL
|
||||
|
||||
init(rootURL: URL) throws {
|
||||
self.rootURL = rootURL
|
||||
try FileManager.default.createDirectory(
|
||||
at: rootURL,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.protectionKey: FileProtectionType.complete]
|
||||
)
|
||||
}
|
||||
|
||||
struct SavedAsset {
|
||||
let relativePath: String
|
||||
let bytes: Int
|
||||
}
|
||||
|
||||
// MARK: - Path Safety
|
||||
|
||||
private func resolveSafePath(_ relativePath: String) throws -> URL {
|
||||
guard !relativePath.contains("/"),
|
||||
!relativePath.contains(".."),
|
||||
!relativePath.isEmpty else {
|
||||
throw FileVaultError.readFailed
|
||||
}
|
||||
let url = rootURL.appendingPathComponent(relativePath)
|
||||
guard url.path.hasPrefix(rootURL.path) else {
|
||||
throw FileVaultError.readFailed
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func writeJPEG(_ image: UIImage, quality: CGFloat = 0.85) throws -> SavedAsset {
|
||||
guard let data = image.jpegData(compressionQuality: quality) else {
|
||||
throw FileVaultError.writeFailed
|
||||
}
|
||||
let filename = "\(UUID().uuidString).jpg"
|
||||
let url = rootURL.appendingPathComponent(filename)
|
||||
try data.write(to: url, options: [.atomic, .completeFileProtection])
|
||||
return SavedAsset(relativePath: filename, bytes: data.count)
|
||||
}
|
||||
|
||||
func loadImage(relativePath: String) throws -> UIImage {
|
||||
let url = try resolveSafePath(relativePath)
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
} catch {
|
||||
throw FileVaultError.readFailed
|
||||
}
|
||||
guard let image = UIImage(data: data) else { throw FileVaultError.decodeFailed }
|
||||
return image
|
||||
}
|
||||
|
||||
func remove(relativePath: String) throws {
|
||||
let url = try resolveSafePath(relativePath)
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
throw FileVaultError.removeFailed
|
||||
}
|
||||
}
|
||||
|
||||
func wipe() throws {
|
||||
let fm = FileManager.default
|
||||
let contents = try fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)
|
||||
for url in contents {
|
||||
try fm.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
151
康康/RootView.swift
Normal file
151
康康/RootView.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
|
||||
enum TjTab: String, Hashable, CaseIterable {
|
||||
case home, trend, me
|
||||
var label: String {
|
||||
switch self {
|
||||
case .home: return "首页"
|
||||
case .trend: return "趋势"
|
||||
case .me: return "我的"
|
||||
}
|
||||
}
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .home: return "house"
|
||||
case .trend: return "chart.line.uptrend.xyaxis"
|
||||
case .me: return "person.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ActiveFlow: Identifiable {
|
||||
case quick, archive
|
||||
var id: String { String(describing: self) }
|
||||
}
|
||||
|
||||
struct RootView: View {
|
||||
@State private var tab: TjTab = .home
|
||||
@State private var showRecordSheet = false
|
||||
@State private var activeFlow: ActiveFlow?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Group {
|
||||
switch tab {
|
||||
case .home: HomeView(onTapArchive: {})
|
||||
case .trend: TrendsView()
|
||||
case .me: MeView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
TabBar(active: tab,
|
||||
onTap: { tab = $0 },
|
||||
onTapRecord: { showRecordSheet = true })
|
||||
}
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.sheet(isPresented: $showRecordSheet) {
|
||||
RecordSheet { kind in
|
||||
showRecordSheet = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
switch kind {
|
||||
case .quick: activeFlow = .quick
|
||||
case .archive: activeFlow = .archive
|
||||
case .diary: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.fullScreenCover(item: $activeFlow) { flow in
|
||||
switch flow {
|
||||
case .quick:
|
||||
QuickCaptureFlow(onClose: { activeFlow = nil })
|
||||
case .archive:
|
||||
ArchiveFlow(onClose: { activeFlow = nil })
|
||||
}
|
||||
}
|
||||
#else
|
||||
.sheet(item: $activeFlow) { flow in
|
||||
switch flow {
|
||||
case .quick:
|
||||
QuickCaptureFlow(onClose: { activeFlow = nil })
|
||||
case .archive:
|
||||
ArchiveFlow(onClose: { activeFlow = nil })
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct TabBar: View {
|
||||
let active: TjTab
|
||||
let onTap: (TjTab) -> Void
|
||||
let onTapRecord: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
tabItem(.home)
|
||||
Color.clear.frame(width: 60, height: 1)
|
||||
tabItem(.trend)
|
||||
tabItem(.me)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 4)
|
||||
.background(
|
||||
Tj.Palette.paper
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(Tj.Palette.lineSoft)
|
||||
.frame(height: 1)
|
||||
}
|
||||
)
|
||||
.overlay(alignment: .top) {
|
||||
recordButton.offset(y: -22)
|
||||
}
|
||||
}
|
||||
|
||||
private func tabItem(_ t: TjTab) -> some View {
|
||||
Button { onTap(t) } label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: t.icon)
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
.frame(width: 26, height: 26)
|
||||
Text(t.label)
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
.foregroundStyle(active == t ? Tj.Palette.ink : Tj.Palette.text3)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var recordButton: some View {
|
||||
Button(action: onTapRecord) {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Tj.Palette.ink)
|
||||
.shadow(color: Color(red: 0.157, green: 0.216, blue: 0.176).opacity(0.25),
|
||||
radius: 12, x: 0, y: 4)
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
|
||||
Text("记录")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
RootView()
|
||||
}
|
||||
Reference in New Issue
Block a user